├── .babelrc ├── .eslintignore ├── .eslintrc ├── .github ├── ISSUE_TEMPLATE.md └── workflows │ └── main.yml ├── .gitignore ├── .npmignore ├── Jenkinsfile ├── LICENSE ├── README.md ├── __mocks__ ├── .eslintrc └── react-native.js ├── __tests__ ├── .eslintrc ├── Backdrop-test.js ├── Menu-test.js ├── MenuOption-test.js ├── MenuOptions-test.js ├── MenuProvider-test.js ├── MenuTrigger-test.js ├── helpers-test.js ├── helpers.js ├── menuRegistry-test.js └── renderers │ ├── ContextMenu-test.js │ ├── MenuOutside-test.js │ ├── NotAnimatedContextMenu-test.js │ ├── Popover-test.js │ └── SlideInMenu-test.js ├── android.demo-popover.gif ├── android.demo.gif ├── build ├── rnpm.js └── rnpm.js.map ├── doc ├── api.md ├── examples.md ├── extensions.md └── img │ ├── basic.png │ ├── checked.png │ ├── context-menu.png │ └── styled.png ├── examples ├── .expo │ ├── packager-info.json │ └── settings.json ├── .watchmanconfig ├── App.js ├── BasicExample.js ├── CloseOnBackExample.js ├── ControlledExample.js ├── Demo.js ├── Example.js ├── ExtensionExample.js ├── FlatListExample.js ├── InFlatListExample.js ├── MenuMethodsExample.js ├── ModalExample.js ├── NavigatorExample.js ├── NonRootExample.js ├── PopoverExample.js ├── StylingExample.js ├── TouchableExample.js ├── __tests__ │ ├── Basic-test.js │ └── __snapshots__ │ │ └── Basic-test.js.snap ├── app.json ├── assets │ ├── icon.png │ └── splash.png ├── babel.config.js ├── package-lock.json └── package.json ├── package.json ├── rollup.config.babel.js ├── setup-jasmine-env.js ├── src ├── Backdrop.js ├── Menu.js ├── MenuOption.js ├── MenuOptions.js ├── MenuPlaceholder.js ├── MenuProvider.js ├── MenuTrigger.js ├── constants.js ├── helpers.js ├── index.d.ts ├── index.js ├── logger.js ├── menuRegistry.js ├── polyfills.js ├── renderers │ ├── ContextMenu.js │ ├── MenuOutside.js │ ├── NotAnimatedContextMenu.js │ ├── Popover.js │ └── SlideInMenu.js └── with-context.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["module:metro-react-native-babel-preset"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | coverage/* 3 | examples/node_modules/* 4 | examples/android/* 5 | examples/ios/* 6 | build/* 7 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | coverage 4 | target 5 | .expo -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | examples 2 | .babelrc 3 | Jenkinsfile 4 | coverage/ 5 | target/ 6 | __tests__/ 7 | __mocks__/ 8 | *.demo.gif 9 | doc/ 10 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-native-popup-menu 2 | [![](https://img.shields.io/npm/dm/react-native-popup-menu.svg?style=flat-square)](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 | |![Popup menu demo](./android.demo.gif)|![Popup menu demo](./android.demo-popover.gif)| 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 | 61 | 62 | 63 | alert(`Save`)} text='Save' /> 64 | alert(`Delete`)} > 65 | Delete 66 | 67 | alert(`Not called`)} disabled={true} text='Disabled' /> 68 | 69 | 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 | -------------------------------------------------------------------------------- /__mocks__/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "globals": { 3 | "jest": true, 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /__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__/.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 | -------------------------------------------------------------------------------- /__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 | -------------------------------------------------------------------------------- /__tests__/Menu-test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { View, Text } from 'react-native'; 3 | import { render } from './helpers'; 4 | 5 | import { MenuTrigger, MenuOptions } from '../src/index'; 6 | 7 | jest.mock('../src/helpers', () => ({ 8 | makeName: () => 'generated-name', 9 | deprecatedComponent: jest.fn(() => jest.fn()), 10 | })); 11 | 12 | jest.dontMock('../src/Menu'); 13 | const exported = require('../src/Menu'); 14 | const { Menu, default: ExportedMenu } = exported; 15 | 16 | const { objectContaining, createSpy, any } = jasmine; 17 | 18 | describe('Menu', () => { 19 | 20 | function renderMenu(element) { 21 | const ctx = createMockContext(); 22 | const result = render(element, ctx); 23 | result.ctx = ctx; 24 | return result; 25 | } 26 | 27 | function createMockContext() { 28 | return { 29 | menuRegistry : { 30 | subscribe: createSpy(), 31 | unsubscribe: createSpy(), 32 | }, 33 | menuActions: { 34 | _notify: createSpy(), 35 | }, 36 | } 37 | } 38 | 39 | it('should export api', () => { 40 | expect(typeof ExportedMenu.debug).toEqual('boolean'); 41 | expect(typeof ExportedMenu.setDefaultRenderer).toEqual('function'); 42 | }); 43 | 44 | it('should render component and preserve children order', () => { 45 | const { output } = renderMenu( 46 | 47 | Some text 48 | 49 | 50 | Some other text 51 | 52 | ); 53 | expect(output.type).toEqual(View); 54 | expect(output.props.children.length).toEqual(3); 55 | // React.Children.toArray modifies components keys 56 | // using the same function to create expected children 57 | const expectedChildren = React.Children.toArray([ 58 | Some text, 59 | , // trigger will be modified 60 | , // options will be removed 61 | Some other text, 62 | ]); 63 | expect(output.props.children[0]).toEqual(expectedChildren[0]); 64 | expect(output.props.children[1]).toEqual(objectContaining({ 65 | type: MenuTrigger, 66 | props: objectContaining({ 67 | onRef: any(Function), 68 | }), 69 | })); 70 | expect(output.props.children[2]).toEqual(expectedChildren[3]); 71 | }); 72 | 73 | it('should subscribe menu and notify context', () => { 74 | const { ctx, instance } = renderMenu( 75 | 76 | 77 | 78 | 79 | ); 80 | instance.componentDidMount(); 81 | expect(ctx.menuRegistry.subscribe).toHaveBeenCalledWith(instance); 82 | expect(ctx.menuActions._notify).toHaveBeenCalled(); 83 | }); 84 | 85 | it('should not subscribe menu because of missing options', () => { 86 | const { instance, renderer, ctx } = renderMenu( 87 | 88 | 89 | Some text 90 | 91 | ); 92 | instance.componentDidMount(); 93 | expect(ctx.menuRegistry.subscribe).not.toHaveBeenCalled(); 94 | const output = renderer.getRenderOutput(); 95 | expect(output.type).toEqual(View); 96 | const expectedChildren = React.Children.toArray([ 97 | , 98 | Some text, 99 | ]); 100 | expect(output.props.children[0]).toEqual( 101 | objectContaining({ 102 | type: MenuTrigger, 103 | }) 104 | ); 105 | expect(output.props.children[1]).toEqual(expectedChildren[1]); 106 | }); 107 | 108 | it('should not subscribe menu because of missing trigger', () => { 109 | const { instance, renderer, ctx } = renderMenu( 110 | 111 | Some text 112 | 113 | 114 | ); 115 | instance.componentDidMount(); 116 | expect(ctx.menuRegistry.subscribe).not.toHaveBeenCalled(); 117 | const output = renderer.getRenderOutput(); 118 | expect(output.type).toEqual(View); 119 | expect(output.props.children).toEqual(React.Children.toArray( 120 | Some text 121 | )); 122 | }); 123 | 124 | it('should not fail without any children', () => { 125 | const { instance, renderer } = renderMenu( 126 | 127 | ); 128 | instance.componentDidMount(); 129 | const output = renderer.getRenderOutput(); 130 | expect(output.type).toEqual(View); 131 | expect(output.props.children).toEqual([]); 132 | instance.componentWillUnmount(); 133 | }); 134 | 135 | it('should autogenerate name if not provided', () => { 136 | const { instance } = renderMenu( 137 | 138 | ); 139 | expect(instance.getName()).toEqual('generated-name'); 140 | }); 141 | 142 | it('should use name from props if provided', () => { 143 | const { instance } = renderMenu( 144 | 145 | ); 146 | expect(instance.getName()).toEqual('prop-name'); 147 | }); 148 | 149 | it('should unsubscribe menu', () => { 150 | const { instance, ctx } = renderMenu( 151 | 152 | 153 | 154 | 155 | ); 156 | instance.componentWillUnmount(); 157 | expect(ctx.menuRegistry.unsubscribe).toHaveBeenCalledWith(instance); 158 | }); 159 | 160 | it('should notify context if updated', () => { 161 | const { instance, ctx } = renderMenu( 162 | 163 | 164 | 165 | 166 | ); 167 | instance.componentDidUpdate({}); 168 | expect(ctx.menuActions._notify).toHaveBeenCalled(); 169 | }); 170 | 171 | it('should get menu options', () => { 172 | const onSelect = () => 0; 173 | const { instance } = renderMenu( 174 | 175 | 176 | 177 | 178 | ); 179 | const options = instance._getOptions(); 180 | expect(options.type).toEqual(MenuOptions); 181 | }); 182 | 183 | it('declarative opened takes precedence over imperative', () => { 184 | const { instance } = renderMenu( 185 | 186 | ); 187 | instance._setOpened(true); 188 | expect(instance.isOpen()).toEqual(false); 189 | expect(instance._getOpened()).toEqual(true); 190 | }); 191 | 192 | it('imperative opened is used if no declarative', () => { 193 | const { instance } = renderMenu( 194 | 195 | ); 196 | instance._setOpened(true); 197 | expect(instance.isOpen()).toEqual(true); 198 | }); 199 | 200 | it('should be considered closed after unmount', () => { 201 | const { instance, ctx } = renderMenu( 202 | 203 | 204 | 205 | 206 | ); 207 | expect(instance.isOpen()).toEqual(true); 208 | instance.componentWillUnmount(); 209 | expect(instance.isOpen()).toEqual(false); 210 | expect(ctx.menuActions._notify).toHaveBeenCalled(); 211 | }); 212 | 213 | it('should know its trigger reference', () => { 214 | const triggerRef = 9; 215 | const { instance, output } = renderMenu( 216 | 217 | 218 | 219 | 220 | ); 221 | output.props.children[0].props.onRef(triggerRef); 222 | expect(instance._getTrigger()).toEqual(triggerRef); 223 | }); 224 | 225 | 226 | }); 227 | -------------------------------------------------------------------------------- /__tests__/MenuOption-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/MenuOption'); 6 | jest.dontMock('../src/helpers'); 7 | const { MenuOption } = require('../src/MenuOption'); 8 | const { createSpy, objectContaining } = jasmine; 9 | 10 | describe('MenuOption', () => { 11 | 12 | const makeMockContext = ({ optionsCustomStyles, onSelect, closeMenu } = {}) => ({ 13 | menuActions: { 14 | _getOpenedMenu: () => ({ 15 | optionsCustomStyles: optionsCustomStyles || {}, 16 | instance: { props: { onSelect: onSelect } }, 17 | }), 18 | closeMenu: closeMenu || createSpy(), 19 | }, 20 | }); 21 | 22 | it('should render component', () => { 23 | const { output } = render( 24 | 25 | Option 1 26 | , 27 | makeMockContext() 28 | ); 29 | expect(output.type).toEqual(TouchableHighlight); 30 | expect(nthChild(output, 1).type).toEqual(View); 31 | expect(nthChild(output, 2)).toEqual( 32 | Option 1 33 | ); 34 | }); 35 | 36 | it('should be enabled by default', () => { 37 | const { instance } = render( 38 | , 39 | makeMockContext() 40 | ); 41 | expect(instance.props.disabled).toBe(false); 42 | }); 43 | 44 | it('should trigger on select event with value', () => { 45 | const spy = createSpy(); 46 | const { renderer } = render( 47 | , 48 | makeMockContext() 49 | ); 50 | const touchable = renderer.getRenderOutput(); 51 | touchable.props.onPress(); 52 | expect(spy).toHaveBeenCalledWith('hello'); 53 | expect(spy.calls.count()).toEqual(1); 54 | }); 55 | 56 | it('should trigger onSelect event from Menu', () => { 57 | const spy = createSpy(); 58 | const { renderer } = render( 59 | , 60 | makeMockContext({ onSelect: spy }) 61 | ); 62 | const touchable = renderer.getRenderOutput(); 63 | touchable.props.onPress(); 64 | expect(spy).toHaveBeenCalledWith('hello'); 65 | expect(spy.calls.count()).toEqual(1); 66 | }); 67 | 68 | it('should close menu on select', () => { 69 | const spy = createSpy(); 70 | const closeMenu = createSpy(); 71 | const { renderer } = render( 72 | , 73 | makeMockContext({ closeMenu }) 74 | ); 75 | const touchable = renderer.getRenderOutput(); 76 | touchable.props.onPress(); 77 | expect(spy).toHaveBeenCalled(); 78 | expect(closeMenu).toHaveBeenCalled(); 79 | }); 80 | 81 | it('should not close menu on select', () => { 82 | const spy = createSpy().and.returnValue(false); 83 | const closeMenu = createSpy() 84 | const { renderer } = render( 85 | , 86 | makeMockContext({ closeMenu }) 87 | ); 88 | const touchable = renderer.getRenderOutput(); 89 | touchable.props.onPress(); 90 | expect(spy).toHaveBeenCalled(); 91 | expect(closeMenu).not.toHaveBeenCalled(); 92 | }); 93 | 94 | it('should not trigger event when disabled', () => { 95 | const spy = createSpy(); 96 | const { output } = render( 97 | , 98 | makeMockContext() 99 | ); 100 | expect(output.type).toBe(View); 101 | expect(output.props.onPress).toBeUndefined(); 102 | }); 103 | 104 | it('should render text passed in props', () => { 105 | const { output } = render( 106 | , 107 | makeMockContext() 108 | ); 109 | expect(output.type).toEqual(TouchableHighlight); 110 | expect(output.props.children.type).toEqual(View); 111 | const text = output.props.children.props.children; 112 | expect(text).toEqual( 113 | Hello world 114 | ); 115 | }); 116 | 117 | it('should render component with custom styles', () => { 118 | const customStyles = { 119 | optionWrapper: { backgroundColor: 'red' }, 120 | optionText: { color: 'blue' }, 121 | optionTouchable: { underlayColor: 'green' }, 122 | }; 123 | const { output } = render( 124 | , 125 | makeMockContext() 126 | ); 127 | const touchable = output; 128 | const view = nthChild(output, 1); 129 | const text = nthChild(output, 2); 130 | expect(normalizeStyle(touchable.props)) 131 | .toEqual(objectContaining({ underlayColor: 'green' })); 132 | expect(normalizeStyle(view.props.style)) 133 | .toEqual(objectContaining(customStyles.optionWrapper)); 134 | expect(normalizeStyle(text.props.style)) 135 | .toEqual(objectContaining(customStyles.optionText)); 136 | }); 137 | 138 | it('should render component with inherited custom styles', () => { 139 | const optionsCustomStyles = { 140 | optionWrapper: { backgroundColor: 'pink' }, 141 | optionText: { color: 'yellow' }, 142 | }; 143 | const customStyles = { 144 | optionText: { color: 'blue' }, 145 | optionTouchable: { underlayColor: 'green' }, 146 | }; 147 | const { output } = render( 148 | , 149 | makeMockContext({ optionsCustomStyles }) 150 | ); 151 | const touchable = output; 152 | const view = nthChild(output, 1); 153 | const text = nthChild(output, 2); 154 | expect(normalizeStyle(touchable.props)) 155 | .toEqual(objectContaining({ underlayColor: 'green' })); 156 | expect(normalizeStyle(view.props.style)) 157 | .toEqual(objectContaining(optionsCustomStyles.optionWrapper)); 158 | expect(normalizeStyle(text.props.style)) 159 | .toEqual(objectContaining(customStyles.optionText)); 160 | }) 161 | 162 | }); 163 | -------------------------------------------------------------------------------- /__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 | -------------------------------------------------------------------------------- /__tests__/MenuProvider-test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { View, Text } from 'react-native'; 3 | import { render, waitFor, mockReactInstance, nthChild } from './helpers'; 4 | import { MenuOptions, MenuTrigger } from '../src/index'; 5 | import MenuOutside from '../src/renderers/MenuOutside'; 6 | import Backdrop from '../src/Backdrop'; 7 | import MenuPlaceholder from '../src/MenuPlaceholder'; 8 | import ContextMenu from '../src/renderers/ContextMenu'; 9 | const { objectContaining, createSpy } = jasmine; 10 | 11 | jest.dontMock('../src/MenuProvider'); 12 | jest.dontMock('../src/menuRegistry'); 13 | 14 | jest.mock('../src/helpers', () => ({ 15 | deprecatedComponent: jest.fn(() => jest.fn()), 16 | measure: () => ({ 17 | then: cb => cb({ 18 | x: 0, 19 | y: 0, 20 | width: 100, 21 | height: 50, 22 | }), 23 | }), 24 | lo: x => x, 25 | isClassComponent: () => false, 26 | iterator2array: it => [...it], 27 | })); 28 | 29 | const {default: MenuProvider, PopupMenuContext} = require('../src/MenuProvider'); 30 | 31 | describe('MenuProvider', () => { 32 | 33 | /* eslint-disable react/display-name */ 34 | function makeMenuStub(name) { 35 | let opened = false; 36 | return { 37 | getName: ()=>name, 38 | isOpen: ()=>opened, 39 | _getOpened: ()=>opened, 40 | _setOpened: (value)=>opened=value, 41 | _getTrigger: ()=>(), 42 | _getOptions: ()=>(), 43 | props : { 44 | onOpen: createSpy(), 45 | onClose: createSpy(), 46 | onBackdropPress: createSpy(), 47 | type: 'context', 48 | renderer: ContextMenu, 49 | }, 50 | } 51 | } 52 | 53 | const defaultLayout = { 54 | nativeEvent: { 55 | layout: { 56 | width: 400, 57 | height: 600, 58 | }, 59 | }, 60 | }; 61 | 62 | let menu1; 63 | 64 | beforeEach(() => { 65 | menu1 = makeMenuStub('menu1'); 66 | }); 67 | 68 | // render menu provider in default configuration and call "standard" lifecycle methods 69 | function renderProvider(props) { 70 | const rendered = render( 71 | 72 | ); 73 | const { instance, output } = rendered; 74 | rendered.placeholder = mockReactInstance(); 75 | instance._onPlaceholderRef(rendered.placeholder); 76 | // for tests mimic old ctx api 77 | const ctx = output.props.value 78 | instance.getChildContext = () => ctx 79 | // and "strip" context provider 80 | rendered.output = nthChild(output, 1) 81 | return rendered; 82 | } 83 | 84 | // renders placeholder and returns array of rendered backdrop and options 85 | function renderPlaceholderChildren(ctx) { 86 | const { output } = render(); 87 | if (output === null) { 88 | return []; 89 | } 90 | return output.props.children; 91 | } 92 | 93 | it('should expose api', () => { 94 | const { instance } = render( 95 | 96 | ); 97 | expect(typeof instance.openMenu).toEqual('function'); 98 | expect(typeof instance.closeMenu).toEqual('function'); 99 | expect(typeof instance.toggleMenu).toEqual('function'); 100 | expect(typeof instance.isMenuOpen).toEqual('function'); 101 | // context is now "renderer" -> see 'should render child components' 102 | }); 103 | 104 | it('should render child components', () => { 105 | let { output } = render( 106 | 107 | 108 | Some text 109 | 110 | ); 111 | // check context 112 | expect(output.type).toEqual(PopupMenuContext.Provider); 113 | const { menuRegistry, menuActions }=output.props.value; 114 | expect(typeof menuRegistry).toEqual('object'); 115 | expect(typeof menuActions).toEqual('object'); 116 | expect(typeof menuActions.openMenu).toEqual('function'); 117 | expect(typeof menuActions.closeMenu).toEqual('function'); 118 | expect(typeof menuActions.toggleMenu).toEqual('function'); 119 | expect(typeof menuActions.isMenuOpen).toEqual('function'); 120 | // plus internal methods 121 | expect(typeof menuActions._notify).toEqual('function'); 122 | // check the rest 123 | output = nthChild(output, 1) 124 | expect(output.type).toEqual(View); 125 | expect(typeof output.props.onLayout).toEqual('function'); 126 | expect(output.props.children.length).toEqual(2); 127 | const [ components, safeArea ] = output.props.children; 128 | expect(safeArea.props.children.length).toEqual(2); 129 | const placeholder = safeArea.props.children[1]; 130 | expect(components.type).toEqual(View); 131 | expect(placeholder.type).toEqual(MenuPlaceholder); 132 | expect(components.props.children).toEqual([ 133 | , 134 | Some text, 135 | ]); 136 | }); 137 | 138 | it('should not render backdrop / options initially', () => { 139 | const { instance } = renderProvider(); 140 | const [ backdrop, options ] = renderPlaceholderChildren(instance); 141 | expect(backdrop).toBeFalsy(); 142 | expect(options).toBeFalsy(); 143 | }); 144 | 145 | it('should open menu', () => { 146 | const { output: initOutput, instance } = renderProvider(); 147 | const { menuRegistry, menuActions } = instance.getChildContext(); 148 | initOutput.props.onLayout(defaultLayout); 149 | menuRegistry.subscribe(menu1); 150 | return menuActions.openMenu('menu1').then(() => { 151 | expect(menuActions.isMenuOpen()).toEqual(true); 152 | expect(menu1._getOpened()).toEqual(true); 153 | initOutput.props.onLayout(defaultLayout); 154 | // next render will start rendering open menu 155 | const [ backdrop, options ] = renderPlaceholderChildren(instance); 156 | expect(backdrop.type).toEqual(Backdrop); 157 | expect(options.type).toEqual(MenuOutside); 158 | // on open was called only once 159 | expect(menu1.props.onOpen.calls.count()).toEqual(1); 160 | }); 161 | }); 162 | 163 | it('should close menu', () => { 164 | const { output: initOutput, instance } = renderProvider(); 165 | const { menuRegistry, menuActions } = instance.getChildContext(); 166 | initOutput.props.onLayout(defaultLayout); 167 | menuRegistry.subscribe(menu1); 168 | return menuActions.openMenu('menu1').then(() => 169 | menuActions.closeMenu().then(() => { 170 | expect(menuActions.isMenuOpen()).toEqual(false); 171 | expect(menu1.props.onClose).toHaveBeenCalled(); 172 | const [ backdrop, options ] = renderPlaceholderChildren(instance); 173 | expect(backdrop).toBeFalsy(); 174 | expect(options).toBeFalsy(); 175 | })); 176 | }); 177 | 178 | it('should toggle menu', () => { 179 | const { instance } = renderProvider(); 180 | const { menuRegistry, menuActions } = instance.getChildContext(); 181 | menuRegistry.subscribe(menu1); 182 | return menuActions.toggleMenu('menu1').then(() => { 183 | expect(menuActions.isMenuOpen()).toEqual(true); 184 | expect(menu1.isOpen()).toEqual(true); 185 | return menuActions.toggleMenu('menu1').then(() => { 186 | expect(menuActions.isMenuOpen()).toEqual(false); 187 | expect(menu1.isOpen()).toEqual(false); 188 | return menuActions.toggleMenu('menu1').then(() => { 189 | expect(menuActions.isMenuOpen()).toEqual(true); 190 | }); 191 | }); 192 | }); 193 | }); 194 | 195 | it('should not open non existing menu', () => { 196 | const { output: initOutput, instance } = renderProvider(); 197 | const { menuRegistry, menuActions } = instance.getChildContext(); 198 | initOutput.props.onLayout(defaultLayout); 199 | menuRegistry.subscribe(menu1); 200 | return menuActions.openMenu('menu_not_existing').then(() => { 201 | expect(menuActions.isMenuOpen()).toEqual(false); 202 | const [ backdrop, options ] = renderPlaceholderChildren(instance); 203 | expect(backdrop).toBeFalsy(); 204 | expect(options).toBeFalsy(); 205 | }); 206 | }); 207 | 208 | it('should not open menu if not initialized', () => { 209 | const { instance } = renderProvider(); 210 | const { menuRegistry, menuActions } = instance.getChildContext(); 211 | menuRegistry.subscribe(menu1); 212 | return menuActions.openMenu('menu1').then(() => { 213 | expect(menuActions.isMenuOpen()).toEqual(true); 214 | const [ backdrop, options ] = renderPlaceholderChildren(instance); 215 | // on layout has not been not called 216 | expect(backdrop).toBeFalsy(); 217 | expect(options).toBeFalsy(); 218 | }); 219 | }); 220 | 221 | it('should update options layout', () => { 222 | const { output: initOutput, instance } = renderProvider(); 223 | const { menuRegistry, menuActions } = instance.getChildContext(); 224 | initOutput.props.onLayout(defaultLayout); 225 | menuRegistry.subscribe(menu1); 226 | return menuActions.openMenu('menu1').then(() => { 227 | const [ , options ] = renderPlaceholderChildren(instance); 228 | expect(typeof options.props.onLayout).toEqual('function'); 229 | options.props.onLayout({ 230 | nativeEvent: { 231 | layout: { 232 | width: 22, 233 | height: 33, 234 | }, 235 | }, 236 | }); 237 | expect(menuRegistry.getMenu('menu1')).toEqual(objectContaining({ 238 | optionsLayout: { 239 | width: 22, 240 | isOutside: true, 241 | height: 33, 242 | }, 243 | })); 244 | }); 245 | }); 246 | 247 | it('should render backdrop that will trigger onBackdropPress', () => { 248 | const { output: initOutput, instance } = renderProvider(); 249 | const { menuRegistry, menuActions } = instance.getChildContext(); 250 | initOutput.props.onLayout(defaultLayout); 251 | menuRegistry.subscribe(menu1); 252 | return menuActions.openMenu('menu1').then(() => { 253 | const [ backdrop ] = renderPlaceholderChildren(instance); 254 | expect(backdrop.type).toEqual(Backdrop); 255 | backdrop.props.onPress(); 256 | expect(menu1.props.onBackdropPress).toHaveBeenCalled(); 257 | }); 258 | }); 259 | 260 | it('should close the menu if backHandler prop is true and back button is pressed', () => { 261 | const { output: initOutput, instance } = renderProvider({backHandler: true}); 262 | const { menuRegistry, menuActions } = instance.getChildContext(); 263 | initOutput.props.onLayout(defaultLayout); 264 | menuRegistry.subscribe(menu1); 265 | return menuActions.openMenu('menu1').then(() => { 266 | instance._handleBackButton(); 267 | return waitFor(() => !instance.isMenuOpen()) 268 | .then(() => false) 269 | .catch(() => true) 270 | .then((isOpen) => expect(isOpen).toEqual(false), 1000); 271 | }) 272 | }); 273 | 274 | it('should not close the menu if backHandler prop is false and back button is pressed', () => { 275 | const { output: initOutput, instance } = renderProvider({backHandler: false}); 276 | const { menuRegistry, menuActions } = instance.getChildContext(); 277 | initOutput.props.onLayout(defaultLayout); 278 | menuRegistry.subscribe(menu1); 279 | return menuActions.openMenu('menu1').then(() => { 280 | instance._handleBackButton(); 281 | expect(instance.isMenuOpen()).toEqual(true); 282 | }) 283 | }); 284 | 285 | it('should invoke custom handler if backHandler prop is a function and back button is pressed', () => { 286 | const handler = jest.fn().mockReturnValue(true); 287 | const { output: initOutput, instance } = renderProvider({backHandler: handler}); 288 | const { menuRegistry, menuActions } = instance.getChildContext(); 289 | initOutput.props.onLayout(defaultLayout); 290 | menuRegistry.subscribe(menu1); 291 | return menuActions.openMenu('menu1').then(() => { 292 | instance._handleBackButton(); 293 | expect(handler.mock.calls).toHaveLength(1); 294 | }) 295 | }); 296 | 297 | }); 298 | -------------------------------------------------------------------------------- /__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 | expect(instance.props.disabled).toBe(false); 42 | }); 43 | 44 | it('should trigger on ref event', () => { 45 | const onRefSpy = createSpy(); 46 | const { output } = render( 47 | 48 | ); 49 | expect(typeof output.ref).toEqual('function'); 50 | output.ref(); 51 | expect(onRefSpy).toHaveBeenCalled(); 52 | expect(onRefSpy.calls.count()).toEqual(1); 53 | }); 54 | 55 | it('should open menu', () => { 56 | const menuActions = { openMenu: createSpy() }; 57 | const { output } = render( 58 | 59 | ); 60 | nthChild(output, 1).props.onPress(); 61 | expect(menuActions.openMenu).toHaveBeenCalledWith('menu1'); 62 | expect(menuActions.openMenu.calls.count()).toEqual(1); 63 | }); 64 | 65 | it('should not open menu when disabled', () => { 66 | const { output, instance } = render( 67 | 68 | ); 69 | const menuActions = { openMenu: createSpy() }; 70 | instance.props.ctx = { menuActions }; 71 | nthChild(output, 1).props.onPress(); 72 | expect(menuActions.openMenu).not.toHaveBeenCalled(); 73 | }); 74 | 75 | it('should render trigger with custom styles', () => { 76 | const customStyles = { 77 | triggerWrapper: { backgroundColor: 'red' }, 78 | triggerText: { color: 'blue' }, 79 | triggerTouchable: { underlayColor: 'green' }, 80 | }; 81 | const { output } = render( 82 | 83 | ); 84 | const touchable = nthChild(output, 1); 85 | const view = nthChild(output, 2); 86 | const text = nthChild(output, 3); 87 | expect(normalizeStyle(touchable.props)) 88 | .toEqual(objectContaining({ underlayColor: 'green' })); 89 | expect(normalizeStyle(view.props.style)) 90 | .toEqual(objectContaining(customStyles.triggerWrapper)); 91 | expect(normalizeStyle(text.props.style)) 92 | .toEqual(objectContaining(customStyles.triggerText)); 93 | }); 94 | 95 | }); 96 | -------------------------------------------------------------------------------- /__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 | -------------------------------------------------------------------------------- /__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 | -------------------------------------------------------------------------------- /__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 | -------------------------------------------------------------------------------- /__tests__/renderers/ContextMenu-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/ContextMenu'); 6 | const { default: ContextMenu, computePosition, fitPositionIntoSafeArea } = require('../../src/renderers/ContextMenu'); 7 | 8 | describe('ContextMenu', () => { 9 | 10 | const windowLayout = { width: 400, height: 600, x: 0, y: 0 }; 11 | const defaultLayouts = { 12 | windowLayout, 13 | triggerLayout: { width: 50, height: 50, x: 10, y: 10 }, 14 | optionsLayout: { width: 200, height: 100 }, 15 | }; 16 | 17 | describe('renderer', () => { 18 | it('should render component', () => { 19 | const { output } = render( 20 | 21 | Some text 22 | Other text 23 | 24 | ); 25 | expect(output.type).toEqual(Animated.View); 26 | expect(output.props.children).toEqual([ 27 | Some text, 28 | Other text, 29 | ]); 30 | }); 31 | }); 32 | 33 | describe('computePosition', () => { 34 | it('should be exported', () => { 35 | expect(typeof ContextMenu.computePosition).toEqual('function'); 36 | }); 37 | 38 | it('should returns default-top-left position', () => { 39 | const triggerLayout = { width: 50, height: 50, x: 100, y: 100 }; 40 | const optionsLayout = { width: 50, height: 50 }; 41 | const layouts = { windowLayout, triggerLayout, optionsLayout }; 42 | expect(computePosition(layouts)).toEqual({ 43 | top: 100, left: 100, 44 | }); 45 | }); 46 | 47 | it('should returns top-left position', () => { 48 | const triggerLayout = { width: 50, height: 50, x: 10, y: 10 }; 49 | const optionsLayout = { width: 50, height: 50 }; 50 | const layouts = { windowLayout, triggerLayout, optionsLayout }; 51 | expect(computePosition(layouts)).toEqual({ 52 | top: 10, left: 10, 53 | }); 54 | }); 55 | 56 | it('should returns top-right position', () => { 57 | const triggerLayout = { width: 100, height: 50, x: 300, y: 0 }; 58 | const optionsLayout = { width: 150, height: 100 }; 59 | const layouts = { windowLayout, triggerLayout, optionsLayout }; 60 | expect(computePosition(layouts)).toEqual({ 61 | top: 0, left: 250, 62 | }); 63 | }); 64 | 65 | it('should returns bottom-left position', () => { 66 | const triggerLayout = { width: 100, height: 100, x: 10, y: 500 }; 67 | const optionsLayout = { width: 150, height: 150 }; 68 | const layouts = { windowLayout, triggerLayout, optionsLayout }; 69 | expect(computePosition(layouts)).toEqual({ 70 | top: 450, left: 10, 71 | }); 72 | }); 73 | 74 | it('should returns bottom-right position', () => { 75 | const triggerLayout = { width: 100, height: 100, x: 300, y: 500 }; 76 | const optionsLayout = { width: 150, height: 150 }; 77 | const layouts = { windowLayout, triggerLayout, optionsLayout }; 78 | expect(computePosition(layouts)).toEqual({ 79 | top: 450, left: 250, 80 | }); 81 | }); 82 | 83 | it('should return horizontal middle position', () => { 84 | const triggerLayout = { width: 100, height: 20, x: 10, y: 290 }; 85 | const optionsLayout = { width: 150, height: 500 }; 86 | const layouts = { windowLayout, triggerLayout, optionsLayout }; 87 | expect(computePosition(layouts)).toEqual({ 88 | top: 50, left: 10, 89 | }); 90 | }); 91 | 92 | it('should return vertical middle position', () => { 93 | const triggerLayout = { width: 100, height: 20, x: 150, y: 10 }; 94 | const optionsLayout = { width: 300, height: 100 }; 95 | const layouts = { windowLayout, triggerLayout, optionsLayout }; 96 | expect(computePosition(layouts)).toEqual({ 97 | top: 10, left: 50, 98 | }); 99 | }); 100 | 101 | it('should return zero top position for big menus', () => { 102 | const triggerLayout = { width: 100, height: 20, x: 10, y: 290 }; 103 | const optionsLayout = { width: 150, height: 700 }; 104 | const layouts = { windowLayout, triggerLayout, optionsLayout }; 105 | expect(computePosition(layouts)).toEqual({ 106 | top: 0, left: 10, 107 | }); 108 | }); 109 | 110 | it('should return zero left position for big menus', () => { 111 | const triggerLayout = { width: 100, height: 20, x: 150, y: 10 }; 112 | const optionsLayout = { width: 500, height: 100 }; 113 | const layouts = { windowLayout, triggerLayout, optionsLayout }; 114 | expect(computePosition(layouts)).toEqual({ 115 | top: 10, left: 0, 116 | }); 117 | }); 118 | 119 | it('should return zero top because of overlaping cener position', () => { 120 | const triggerLayout = { width: 100, height: 20, x: 10, y: 200 }; 121 | const optionsLayout = { width: 150, height: 500 }; 122 | const layouts = { windowLayout, triggerLayout, optionsLayout }; 123 | expect(computePosition(layouts)).toEqual({ 124 | top: 0, left: 10, 125 | }); 126 | }); 127 | 128 | it('should return zero bottom because of overlaping cener position', () => { 129 | const triggerLayout = { width: 100, height: 20, x: 10, y: 450 }; 130 | const optionsLayout = { width: 150, height: 500 }; 131 | const layouts = { windowLayout, triggerLayout, optionsLayout }; 132 | expect(computePosition(layouts)).toEqual({ 133 | top: 100, left: 10, 134 | }); 135 | }); 136 | 137 | it('should return zero left because of overlaping cener position', () => { 138 | const triggerLayout = { width: 1, height: 20, x: 100, y: 10 }; 139 | const optionsLayout = { width: 350, height: 50 }; 140 | const layouts = { windowLayout, triggerLayout, optionsLayout }; 141 | expect(computePosition(layouts)).toEqual({ 142 | top: 10, left: 0, 143 | }); 144 | }); 145 | 146 | it('should consider window offset', () => { 147 | const windowLayout = { width: 400, height: 600, x: 20, y: 30 }; 148 | const triggerLayout = { width: 50, height: 50, x: 100, y: 100 }; 149 | const optionsLayout = { width: 50, height: 50 }; 150 | const layouts = { windowLayout, triggerLayout, optionsLayout }; 151 | expect(computePosition(layouts)).toEqual({ 152 | top: 70, left: 80, 153 | }); 154 | }); 155 | 156 | }); 157 | 158 | describe('fitPositionIntoSafeArea', () => { 159 | const optionsLayout = { width: 50, height: 50 }; 160 | const safeAreaLayout = { width: 300, height: 500, x: 20, y: 30 }; 161 | const defaultLayouts = { optionsLayout, windowLayout, safeAreaLayout}; 162 | 163 | it('should be exported', () => { 164 | expect(typeof ContextMenu.fitPositionIntoSafeArea).toEqual('function'); 165 | }); 166 | 167 | it('should be identity without safe area', () => { 168 | const layouts = { optionsLayout }; 169 | const position = { top: 70, left: 80 }; 170 | expect(fitPositionIntoSafeArea(position, layouts)).toEqual(position); 171 | }); 172 | 173 | it('should avoid top/left edge', () => { 174 | const position = { top: 10, left: 10 }; 175 | expect(fitPositionIntoSafeArea(position, defaultLayouts)).toEqual({ 176 | top: 30, 177 | left: 20, 178 | }); 179 | }); 180 | 181 | it('should avoid top/right edge (RTL)', () => { 182 | const position = { top: 10, right: 10 }; 183 | expect(fitPositionIntoSafeArea(position, defaultLayouts)).toEqual({ 184 | top: 30, 185 | right: 80, // window - safeArea end 186 | }); 187 | }); 188 | 189 | it('should avoid bottom/right edge', () => { 190 | const position = { top: 490, left: 290 }; 191 | expect(fitPositionIntoSafeArea(position, defaultLayouts)).toEqual({ 192 | top: 480, 193 | left: 270, 194 | }); 195 | }); 196 | 197 | }); 198 | 199 | }); 200 | -------------------------------------------------------------------------------- /__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 | -------------------------------------------------------------------------------- /__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 | -------------------------------------------------------------------------------- /__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 | -------------------------------------------------------------------------------- /__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 | -------------------------------------------------------------------------------- /android.demo-popover.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/instea/react-native-popup-menu/5c047c1423c2f0003ed4412d176adafd01e616a4/android.demo-popover.gif -------------------------------------------------------------------------------- /android.demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/instea/react-native-popup-menu/5c047c1423c2f0003ed4412d176adafd01e616a4/android.demo.gif -------------------------------------------------------------------------------- /doc/api.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | ## MenuProvider 4 | 5 | It provides methods to handle popup menus imperatively. The same methods are exposed to the context property `menuActions`. This can be retrieved by HOC `withMenuContext` that adds `ctx` property to your component. Then simply call `props.ctx.menuActions.method()`. 6 | 7 | **Note:** It is important that `` is on the top of the component hierarchy (e.g. `ScrollView` should be inside of `MenuProvider`) and wraps all `` components. 8 | This is needed in order to solve z-index issues. 9 | The only known exception is when you use [Modal](https://facebook.github.io/react-native/docs/modal.html) - you need to place (additional) 'MenuProvider' inside of 'Modal' (see our [ModalExample](../examples/ModalExample.js)) 10 | **Note:** `MenuProvider` was formerly named `MenuContext` which is now deprecated. 11 | 12 | ### Methods, `menuActions` context 13 | 14 | | Method Name | Arguments | Notes 15 | |---|---|---| 16 | |`openMenu`|`name`|Opens menu by name. Returns promise| 17 | |`toggleMenu`|`name`|Toggle menu by name. Returns promise| 18 | |`closeMenu`||Closes currently opened menu. Returns promise| 19 | |`isMenuOpen`||Returns `true` if any menu is open| 20 | 21 | ### Properties 22 | | Option | Type | Opt/Required | Default | Note | 23 | |---|---|---|---|---| 24 | |`style`|`Style`|Optional||Style of wrapping `View` component. Same as `customStyles.menuProviderWrapper` but when both are present result style is a merge where this style has higher precedence.| 25 | |`customStyles`|`Object`|Optional||Object defining wrapper, touchable and text styles| 26 | |`backHandler`|`boolean\|Function`|Optional|false|Whether to close the menu when the back button is pressed or custom back button handler if a function is passed (RN >= 0.44 is required)| 27 | |`skipInstanceCheck`|`boolean`|Optional|false|Normally your application should have only one menu provider (with exception as discussed above). If you really need more instances, set `skipInstanceCheck` to `true` to disable the check (and following warning message)| 28 | 29 | ### Custom styles 30 | 31 | To style `` and backdrop component you can pass `customStyles` object prop with following keys: 32 | 33 | | Object key | Type | Notes | 34 | |---|---|---| 35 | |`menuProviderWrapper`|`Style`|Style of wrapping `View` component (formerly `menuContextWrapper`)| 36 | |`backdrop`|`Style`|Backdrop `View` style| 37 | 38 | **Note:** `Style` type is any valid RN style parameter. 39 | **Note:** In addition to these styles we add also `{flex:1}`. You can disable it by e.g. `style={{flex:0}}`. 40 | 41 | See more in custom [styling example](../examples/StylingExample.js). 42 | 43 | ### Handling of back button 44 | 45 | To handle the back button you can pass `backHandler` prop with the following possible values: 46 | 47 | | Value | Description | 48 | |---|---| 49 | |false|No handling of back button press| 50 | |true|The menu will be closed| 51 | |Function|The function will be called with `MenuProvider` instance as the first parameter. The function needs to return true to prevent application exit (or bubbling if there are other listeners registered). Read [BackHandler documentation](https://facebook.github.io/react-native/docs/backhandler.html) for more information.| 52 | 53 | See more in custom [close on back example](../examples/CloseOnBackExample.js). 54 | 55 | ## Menu 56 | 57 | Root menu component defining menu name and providing menu events. 58 | 59 | ### Methods 60 | | Method Name | Arguments | Notes 61 | |---|---|---| 62 | |`open`||Opens menu. Returns promise| 63 | |`isOpen`||Returns wheter the menu is open or not| 64 | |`close`||Closes menu. Returns promise| 65 | 66 | ### Properties 67 | | Option | Type | Opt/Required | Default | Note | 68 | |---|---|---|---|---| 69 | |`name`|`String`|Optional|`auto-generated`|Unique name of menu| 70 | |`opened`|`Boolean`|Optional||Declaratively states if menu is opened. When this prop is provided, menu is controlled and imperative API won't work.| 71 | |`renderer`|`Function`|Optional|`ContextMenu`|Defines position, animation and basic menu styles. See [renderers section](#renderers) for more details| 72 | |`rendererProps`|`Object`|Optional||Additional props which will be passed to the renderer| 73 | 74 | ### Events 75 | | Event Name | Arguments | Notes | 76 | |---|---|---| 77 | |`onSelect`|`optionValue`|Triggered when menu option is selected. When event handler returns `false`, the popup menu remains open| 78 | |`onOpen`||Triggered when menu is opened| 79 | |`onClose`||Triggered when menu is closed| 80 | |`onBackdropPress`||Triggered when user press backdrop (outside of the opened menu)| 81 | 82 | ### Static Properties 83 | | Property name | Type | Opt/Required | Default | Note | 84 | |---|---|---|---|---| 85 | |`debug`|`Boolean`|Optional|`false`|This property enables debug logs| 86 | 87 | ### Static Functions 88 | | Function name | Arguments | Returns | Note | 89 | |---|---|---|---| 90 | |`setDefaultRenderer`| `Function`| | Sets new default renderer. See [renderers section](#renderers) for more details | 91 | |`setDefaultRendererProps`| `Object`| | Sets new default renderer props | 92 | 93 | ## MenuTrigger 94 | 95 | It defines position where the popup menu will be rendered. 96 | Menu can by opened by clicking on `` or by calling context methods. 97 | 98 | **Note:** It is necessary that `` is a direct child of ``. 99 | 100 | ### Properties 101 | | Option | Type | Opt/Required | Default | Note | 102 | |---|---|---|---|---| 103 | |`disabled`|`Boolean`|Optional|`false`|Indicates if trigger can be pressed| 104 | |`children`|`Elements`|Optional||React elements to render as menu trigger. Exclusive with `text` property| 105 | |`text`|`String`|Optional||Text to be rendered. When this prop is provided, trigger's children won't be rendered| 106 | |`customStyles`|`Object`|Optional||Object defining wrapper, touchable and text styles| 107 | |`triggerOnLongPress`|`Boolean`|Optional|`false`|If `true`, menu will trigger onLongPress instead of onPress| 108 | |`testID`|`String`|Optional|| Used for e2e testing to get Touchable element| 109 | 110 | ### Events 111 | | Event Name | Arguments | Notes | 112 | |---|---|---| 113 | |`onPress`||Triggered when trigger is pressed (or longpressed depending on `triggerOnLongPress`)| 114 | |`onAlternativeAction`||Triggered when trigger is longpressed (or pressed depending on `triggerOnLongPress`)| 115 | 116 | ### Custom styles 117 | 118 | To style `` component you can pass `customStyles` object prop with following keys: 119 | 120 | | Object key | Type | Notes | 121 | |---|---|---| 122 | |`triggerOuterWrapper`|`Style`|Style of outer `View` component| 123 | |`triggerWrapper`|`Style`|Style of inner `View` component (can be overriden by `style` prop)| 124 | |`triggerText`|`Style`|Style of `Text` component (used when `text` shorthand option is defined)| 125 | |`TriggerTouchableComponent`|`Component`|Touchable component of trigger. Default value is `TouchableHighlight` for iOS and `TouchableNativeFeedvack` for Android| 126 | |`triggerTouchable`|`Object`|Properties passed to the touchable component (e.g. `activeOpacity`, `underlayColor` for `TouchableHighlight`)| 127 | 128 | **Note:** `Style` type is any valid RN style parameter. 129 | 130 | See more in custom [styling example](../examples/StylingExample.js) and [touchable example](../examples/TouchableExample.js). 131 | 132 | ## MenuOptions 133 | 134 | This component wrapps all menu options. 135 | 136 | **Note:** It is necessary that `` is a direct child of ``. 137 | 138 | ### Properties 139 | | Option | Type | Opt/Required | Default | Note | 140 | |---|---|---|---|---| 141 | |`optionsContainerStyle`|`Style`|Optional||Custom styles for options container. Note: this option is deprecated, use `customStyles` option instead| 142 | |`renderOptionsContainer`|`Func`|Optional|`options => options`|Custom render function for ``. It takes options component as argument and returns component. E.g.: `options => {options} (Deprecated)`| 143 | |`customStyles`|`Object`|Optional||Object defining wrapper, touchable and text styles| 144 | 145 | ### Custom styles 146 | 147 | To style `` and it's `` components you can pass `customStyles` object prop with following keys: 148 | 149 | | Object key | Type | Notes | 150 | |---|---|---| 151 | |`optionsWrapper`|`Style`|Style of wrapping `MenuOptions` component (can be overriden by `style` prop)| 152 | |`optionsContainer`|`Style`|Style passed to the menu renderer (e.g. `Animated.View`)| 153 | |`optionWrapper`|`Style`|Style of `View` component wrapping single option| 154 | |`optionText`|`Style`|Style of `Text` component (when `text` shorthand option is defined)| 155 | |`OptionTouchableComponent`|`Component`|Touchable component of option. Default value is `TouchableHighlight` for iOS and `TouchableNativeFeedvack` for Android| 156 | |`optionTouchable`|`Object`|Properties passed to the touchable component (e.g. `activeOpacity`, `underlayColor` for `TouchableHighlight`)| 157 | 158 | **Note:** `optionWrapper`, `optionTouchable` and `optionText` styles of particular menu option can be overriden by `customStyles` prop of `` component. 159 | 160 | **Note:** In order to change `customStyles` dynamically, it is required that no child of `MenuOptions` stops the update (e.g. `shouldComponentUpdate` returning `false`). 161 | 162 | **Note:** `Style` type is any valid RN style parameter. 163 | 164 | See more in custom [styling example](../examples/StylingExample.js) and [touchable example](../examples/TouchableExample.js). 165 | 166 | ## MenuOption 167 | 168 | Wrapper component of menu option. 169 | 170 | ### Properties 171 | | Option | Type | Opt/Required | Default | Note | 172 | |---|---|---|---|---| 173 | |`value`|`Any`|Optional||Value of option| 174 | |`children`|`Elements`|Optional||React elements to render as menu option. Exclusive with `text` property| 175 | |`text`|`String`|Optional||Text to be rendered. When this prop is provided, option's children won't be rendered| 176 | |`disabled`|`Boolean`|Optional|`false`|Indicates if option can be pressed| 177 | |`disableTouchable`|`Boolean`|Optional|`false`|Disables Touchable wrapper (no on press effect and no onSelect execution) Note: Alternatively you don't have to use `MenuOption` at all if you want render something "non-selectable" in the menu (e.g. divider)| 178 | |`customStyles`|`Object`|Optional||Object defining wrapper, touchable and text styles| 179 | |`testID`|`String`|Optional|| Used for e2e testing to get Touchable element. If `disableTouchable=true`, it is not available | 180 | ### Events 181 | | Event Name | Arguments | Notes | 182 | |---|---|---| 183 | |`onSelect`||Triggered when option is selected. When event handler returns `false`, the popup menu remains open. Note: If this event handler is defined, it suppress `onSelect` handler of ``| 184 | 185 | ### Custom styles 186 | 187 | To style `` component you can pass `customStyles` object prop with following keys: 188 | 189 | | Object key | Type | Notes | 190 | |---|---|---| 191 | |`optionWrapper`|`Style`|Style of wrapping `View` component.| 192 | |`optionText`|`Style`|Style of `Text` component (when `text` shorthand option is defined)| 193 | |`OptionTouchableComponent`|`Component`|Touchable component of option. Default value is `TouchableHighlight` for iOS and `TouchableNativeFeedvack` for Android| 194 | |`optionTouchable`|`Object`|Properties passed to the touchable component (e.g. `activeOpacity`, `underlayColor` for `TouchableHighlight`)| 195 | 196 | **Note:** `Style` type is any valid RN style parameter. 197 | 198 | See more in custom [styling example](../examples/StylingExample.js) and [touchable example](../examples/TouchableExample.js). 199 | 200 | ## Renderers 201 | Renderers are react components which wraps `MenuOptions` and are responsible for menu position and animation. 202 | It is possible to extend menu and use custom renderer (see implementation of existing renderers or [extension guide](extensions.md)). 203 | All renderers can be found in `renderers` module. 204 | 205 | **Note:** Renderers can be customized by props which can be passed through `rendererProps` option or `setDefaultRendererProps` static method. 206 | 207 | ### `ContextMenu` (default) 208 | Opens (animated) context menu over the trigger position. The `ContextMenu.computePosition` exports function for position calculation in case you would like to implement your own renderer (without special position calculation). 209 | 210 | ### `NotAnimatedContextMenu` 211 | Same as ContextMenu but without any animation. 212 | 213 | ### `SlideInMenu` 214 | Slides in the menu from the bottom of the screen. 215 | 216 | ### `Popover` 217 | Displays menu as a popover. Popover can be customized by following props: 218 | 219 | | Option | Type | Opt/Required | Default | Note | 220 | |---|---|---|---|---| 221 | |`placement`|`String`|Optional|`auto`|Position of popover to the menu trigger - `top` | `right` | `bottom` | `left` | `auto`| 222 | |`preferredPlacement`|`String`|Optional|`top`|Preferred placement of popover - `top` | `right` | `bottom` | `left`. Applicable when placement is set to `auto`| 223 | |`anchorStyle`|`Style`|Optional||Styles passed to popover anchor component| 224 | |`openAnimationDuration`|`Number`|Optional|`225`|Duration of animation to show the popover| 225 | |`closeAnimationDuration`|`Number`|Optional|`195`|Duration of animation to hide the popover| 226 | -------------------------------------------------------------------------------- /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 | ![basic](img/basic.png) 7 | ```js 8 | alert(`Selected number: ${value}`)}> 9 | 10 | 11 | 12 | 13 | Two 14 | 15 | 16 | 17 | 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 | 28 | 29 | 30 | this.toggleHighlight(l.id)} text={l.highlighted ? 'Unhighlight' : 'Highlight'} /> 31 | 32 | this.deleteLogItem(l.id)} text='Delete' /> 33 | 34 | 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 | this.onBackdropPress()} 45 | onSelect={value => this.onOptionSelect(value)}> 46 | this.onTriggerPress()} 48 | text='Select option'/> 49 | ``` 50 | 51 | ## Slide in menu 52 | [Example](../examples/Example.js): 53 | In addition to default popup menu, menu (options) can slide in from bottom of the screen. 54 | ```js 55 | import { renderers } from 'react-native-popup-menu'; 56 | const { SlideInMenu } = renderers; 57 | ... 58 | this.selectNumber(value)}> 59 | 60 | Slide-in menu... 61 | 62 | 63 | ``` 64 | You can select one of our provided `renderers` or you can write your own renderer. 65 | This allow you to define animations, position of menu and much more. 66 | Renderer can be set on menu level or globally via `Menu.setDefaultRenderer`. 67 | 68 | For more details see [extensions](extensions.md) documentation. 69 | 70 | ## Custom menu options 71 | [ExtensionExample](../examples/ExtensionExample.js): 72 | You can also define your own `MenuOption`s if you want to use more complex options often. 73 | Another nice use case is to have menu options with icons. 74 | 75 | ![checked](img/checked.png) 76 | ```js 77 | 78 | 79 | 80 | 81 | ... 82 | const CheckedOption = (props) => ( 83 | 84 | ) 85 | ``` 86 | 87 | ## Menu within scroll view 88 | If you want to display menu options in scroll view, simply wrap all menu options with `` component. For example: 89 | 90 | ```js 91 | 92 | 93 | 94 | 95 | ... 96 | 97 | 98 | ``` 99 | 100 | You can also check our [FlatListExample](../examples/FlatListExample.js). 101 | 102 | ## Styled menu 103 | [StylingExample](../examples/StylingExample.js): 104 | Although you can style options and triggers directly via its children, 105 | you can achieve almost any styling `customStyles` property on various levels. 106 | 107 | ![styled](img/styled.png) 108 | ```js 109 | 110 | 111 | 112 | this.setState({renderer: ContextMenu})}/> 114 | this.setState({renderer: SlideInMenu})}/> 116 | alert('Selected custom styled option')} /> 118 | 119 | Four (disabled) 120 | 121 | 122 | 123 | ... 124 | const triggerStyles = { 125 | triggerText: { 126 | color: 'white', 127 | }, 128 | triggerWrapper: { 129 | padding: 5, 130 | backgroundColor: 'blue', 131 | }, 132 | triggerTouchable: { 133 | underlayColor: 'darkblue', 134 | activeOpacity: 70, 135 | }, 136 | TriggerTouchableComponent: TouchableHighlight, 137 | }; 138 | ``` 139 | For exact definitions of `customStyles` please refer to [API](api.md). 140 | Also note that `MenuOption` can have either `value` or directly attached `onSelect` handler as in this case. 141 | 142 | 143 | More examples can be found in [examples](../examples/) directory. 144 | -------------------------------------------------------------------------------- /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 | 21 | 22 | 23 | 24 | Some text 25 | 26 | 27 | 28 | 29 | 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 | 45 | 46 | 47 | 48 | 49 | 50 | 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 | 62 | ... 63 | 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 | -------------------------------------------------------------------------------- /doc/img/basic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/instea/react-native-popup-menu/5c047c1423c2f0003ed4412d176adafd01e616a4/doc/img/basic.png -------------------------------------------------------------------------------- /doc/img/checked.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/instea/react-native-popup-menu/5c047c1423c2f0003ed4412d176adafd01e616a4/doc/img/checked.png -------------------------------------------------------------------------------- /doc/img/context-menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/instea/react-native-popup-menu/5c047c1423c2f0003ed4412d176adafd01e616a4/doc/img/context-menu.png -------------------------------------------------------------------------------- /doc/img/styled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/instea/react-native-popup-menu/5c047c1423c2f0003ed4412d176adafd01e616a4/doc/img/styled.png -------------------------------------------------------------------------------- /examples/.expo/packager-info.json: -------------------------------------------------------------------------------- 1 | { 2 | "devToolsPort": 19002, 3 | "expoServerPort": null, 4 | "packagerPort": null, 5 | "packagerPid": null, 6 | "expoServerNgrokUrl": null, 7 | "packagerNgrokUrl": null, 8 | "ngrokPid": null, 9 | "webpackServerPort": null 10 | } 11 | -------------------------------------------------------------------------------- /examples/.expo/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "hostType": "lan", 3 | "lanType": "ip", 4 | "dev": true, 5 | "minify": false, 6 | "urlRandomness": "w3-vvy", 7 | "https": false, 8 | "scheme": null 9 | } 10 | -------------------------------------------------------------------------------- /examples/.watchmanconfig: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /examples/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Demo from './Demo'; 3 | 4 | export default class App extends React.Component { 5 | render() { 6 | return ( 7 | 8 | ); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /examples/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/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 |