├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── main.yml ├── .gitignore ├── .watchmanconfig ├── .yarnrc ├── CHANGELOGS.md ├── LICENSE ├── README.md ├── babel.config.js ├── example ├── App.js ├── app.json ├── assets │ ├── adaptive-icon.png │ ├── favicon.png │ ├── icon.png │ └── splash.png ├── babel.config.js ├── metro.config.js ├── package.json ├── src │ └── App.tsx ├── tsconfig.json ├── webpack.config.js └── yarn.lock ├── lefthook.yml ├── package.json ├── scripts └── bootstrap.js ├── src ├── components │ ├── container.tsx │ ├── event-provider.tsx │ └── outside-press-handler.tsx ├── event-context.ts ├── hooks │ ├── use-event-store.ts │ └── use-event.ts ├── index.ts └── utils │ └── deep-clone.ts ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: dcangulo 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | day: "saturday" 8 | time: "04:00" 9 | timezone: "Asia/Manila" 10 | - package-ecosystem: "npm" 11 | directory: "/" 12 | schedule: 13 | interval: "weekly" 14 | day: "saturday" 15 | time: "04:00" 16 | timezone: "Asia/Manila" 17 | open-pull-requests-limit: 99 18 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | on: [push] 3 | jobs: 4 | main: 5 | name: main 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Check out code 9 | uses: actions/checkout@v4 10 | - name: Setup environment 11 | uses: actions/setup-node@v4 12 | - name: Install dependencies 13 | run: yarn 14 | - name: Check types 15 | run: yarn typecheck 16 | - name: Run lint 17 | run: yarn lint 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # XDE 6 | .expo/ 7 | 8 | # VSCode 9 | .vscode/ 10 | jsconfig.json 11 | 12 | # Xcode 13 | # 14 | build/ 15 | *.pbxuser 16 | !default.pbxuser 17 | *.mode1v3 18 | !default.mode1v3 19 | *.mode2v3 20 | !default.mode2v3 21 | *.perspectivev3 22 | !default.perspectivev3 23 | xcuserdata 24 | *.xccheckout 25 | *.moved-aside 26 | DerivedData 27 | *.hmap 28 | *.ipa 29 | *.xcuserstate 30 | project.xcworkspace 31 | 32 | # Android/IJ 33 | # 34 | .classpath 35 | .cxx 36 | .gradle 37 | .idea 38 | .project 39 | .settings 40 | local.properties 41 | android.iml 42 | 43 | # Cocoapods 44 | # 45 | example/ios/Pods 46 | 47 | # Ruby 48 | example/vendor/ 49 | 50 | # node.js 51 | # 52 | node_modules/ 53 | npm-debug.log 54 | yarn-debug.log 55 | yarn-error.log 56 | 57 | # BUCK 58 | buck-out/ 59 | \.buckd/ 60 | android/app/libs 61 | android/keystores/debug.keystore 62 | 63 | # Expo 64 | .expo/ 65 | 66 | # Turborepo 67 | .turbo/ 68 | 69 | # generated by bob 70 | lib/ 71 | -------------------------------------------------------------------------------- /.watchmanconfig: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | # Override Yarn command so we can automatically setup the repo on running `yarn` 2 | 3 | yarn-path "scripts/bootstrap.js" 4 | -------------------------------------------------------------------------------- /CHANGELOGS.md: -------------------------------------------------------------------------------- 1 | # Changelogs 2 | 3 | ## 1.2.2 (2024-01-02) 4 | * Removed `engines` from `package.json`. ([#327](https://github.com/dcangulo/react-native-outside-press/pull/327) by [@gulewei](https://github.com/gulewei)) 5 | 6 | ## 1.2.1 (2023-08-19) 7 | * Updated documentation. 8 | * Added default style to container. 9 | * Make `disabled` prop optional. 10 | 11 | ## 1.2.0 (2023-08-14) 12 | * Updated project template. 13 | * Fixed outside press event not firing on web. 14 | 15 | ## 1.1.0 (2022-08-10) 16 | * Added `disabled` prop to `OutsidePressHandler`. 17 | 18 | ## 1.0.2 (2022-07-12) 19 | * Fixed unexpected behavior on iOS and Android. 20 | 21 | ## 1.0.1 (2022-07-11) 22 | * Fixed a bug where a dev dependency is in dependencies. 23 | 24 | ## 1.0.0 (2022-07-10) 25 | * Initial release. 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 David Angulo 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Native Outside Press 2 | [![Package version](https://img.shields.io/npm/v/react-native-outside-press?style=for-the-badge&labelColor=000000)](https://www.npmjs.com/package/react-native-outside-press) 3 | [![MIT license](https://img.shields.io/badge/License-MIT-brightgreen.svg?style=for-the-badge&labelColor=000000)](LICENSE) 4 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-hotpink.svg?style=for-the-badge&labelColor=000000)](https://github.com/dcangulo/react-native-outside-press/pulls) 5 | 6 | [airbnb/react-outside-click-handler](https://github.com/airbnb/react-outside-click-handler) but for React Native. 7 | 8 | ## Compatibility 9 | | iOS | Android | Web | Windows | macOS | Expo | 10 | |--------------------|--------------------|--------------------|--------------------|--------------------|--------------------| 11 | | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | 12 | 13 | ## Installation 14 | ```bash 15 | yarn add react-native-outside-press 16 | ``` 17 | 18 | ## Usage 19 | 20 | ### EventProvider 21 | Wrap your app with `EventProvider`. 22 | 23 | ```js 24 | import { EventProvider } from 'react-native-outside-press'; 25 | 26 | export default function App() { 27 | return ( 28 | 29 | 30 | 31 | ); 32 | } 33 | ``` 34 | 35 | #### Props 36 | 37 | | Name | Description | Type | Default | Required? | 38 | |-------------|---------------------|------------------------------------------------------------|---------------|-----------| 39 | | `style` | | [ViewStyle](https://reactnative.dev/docs/view-style-props) | `{ flex: 1 }` | `false` | 40 | | `ViewProps` | Inherits ViewProps. | [ViewProps](https://reactnative.dev/docs/view#props) | | `false` | 41 | 42 | ### OutsidePressHandler 43 | Wrap every component you want to detect outside press with `OutsidePressHandler`. 44 | 45 | ```js 46 | import { View } from 'react-native'; 47 | import OutsidePressHandler from 'react-native-outside-press'; 48 | 49 | export default function MyComponent() { 50 | return ( 51 | { 53 | console.log('Pressed outside the box!'); 54 | }} 55 | > 56 | 57 | 58 | ); 59 | } 60 | ``` 61 | 62 | #### Props 63 | 64 | | Name | Description | Type | Default | Required? | 65 | |------------------|------------------------------------------------------|------------------------------------------------------|---------------|-----------| 66 | | `onOutsidePress` | Function to run when pressed outside of component. | function | | `true` | 67 | | `disabled` | Controls whether `onOutsidePress` should run or not. | boolean | `false` | `false` | 68 | | `ViewProps` | Inherits ViewProps. | [ViewProps](https://reactnative.dev/docs/view#props) | | `false` | 69 | 70 | ## Changelogs 71 | See [CHANGELOGS.md](CHANGELOGS.md) 72 | 73 | ## License 74 | Copyright © 2023 David Angulo, released under the MIT license, see [LICENSE](LICENSE). 75 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['module:metro-react-native-babel-preset'], 3 | }; 4 | -------------------------------------------------------------------------------- /example/App.js: -------------------------------------------------------------------------------- 1 | export { default } from './src/App'; 2 | -------------------------------------------------------------------------------- /example/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "example", 4 | "slug": "example", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/icon.png", 8 | "userInterfaceStyle": "light", 9 | "splash": { 10 | "image": "./assets/splash.png", 11 | "resizeMode": "contain", 12 | "backgroundColor": "#ffffff" 13 | }, 14 | "assetBundlePatterns": [ 15 | "**/*" 16 | ], 17 | "ios": { 18 | "supportsTablet": true 19 | }, 20 | "android": { 21 | "adaptiveIcon": { 22 | "foregroundImage": "./assets/adaptive-icon.png", 23 | "backgroundColor": "#ffffff" 24 | } 25 | }, 26 | "web": { 27 | "favicon": "./assets/favicon.png" 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /example/assets/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dcangulo/react-native-outside-press/fbeac099fa29631b693d8d5db8fcfee24d23dd9b/example/assets/adaptive-icon.png -------------------------------------------------------------------------------- /example/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dcangulo/react-native-outside-press/fbeac099fa29631b693d8d5db8fcfee24d23dd9b/example/assets/favicon.png -------------------------------------------------------------------------------- /example/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dcangulo/react-native-outside-press/fbeac099fa29631b693d8d5db8fcfee24d23dd9b/example/assets/icon.png -------------------------------------------------------------------------------- /example/assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dcangulo/react-native-outside-press/fbeac099fa29631b693d8d5db8fcfee24d23dd9b/example/assets/splash.png -------------------------------------------------------------------------------- /example/babel.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const pak = require('../package.json'); 3 | 4 | module.exports = function (api) { 5 | api.cache(true); 6 | 7 | return { 8 | presets: ['babel-preset-expo'], 9 | plugins: [ 10 | [ 11 | 'module-resolver', 12 | { 13 | extensions: ['.tsx', '.ts', '.js', '.json'], 14 | alias: { 15 | // For development, we want to alias the library to the source 16 | [pak.name]: path.join(__dirname, '..', pak.source), 17 | }, 18 | }, 19 | ], 20 | ], 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /example/metro.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const escape = require('escape-string-regexp'); 3 | const { getDefaultConfig } = require('@expo/metro-config'); 4 | const exclusionList = require('metro-config/src/defaults/exclusionList'); 5 | const pak = require('../package.json'); 6 | 7 | const root = path.resolve(__dirname, '..'); 8 | const modules = Object.keys({ ...pak.peerDependencies }); 9 | 10 | const defaultConfig = getDefaultConfig(__dirname); 11 | 12 | /** 13 | * Metro configuration 14 | * https://facebook.github.io/metro/docs/configuration 15 | * 16 | * @type {import('metro-config').MetroConfig} 17 | */ 18 | const config = { 19 | ...defaultConfig, 20 | 21 | projectRoot: __dirname, 22 | watchFolders: [root], 23 | 24 | // We need to make sure that only one version is loaded for peerDependencies 25 | // So we block them at the root, and alias them to the versions in example's node_modules 26 | resolver: { 27 | ...defaultConfig.resolver, 28 | 29 | blacklistRE: exclusionList( 30 | modules.map( 31 | (m) => 32 | new RegExp(`^${escape(path.join(root, 'node_modules', m))}\\/.*$`) 33 | ) 34 | ), 35 | 36 | extraNodeModules: modules.reduce((acc, name) => { 37 | acc[name] = path.join(__dirname, 'node_modules', name); 38 | return acc; 39 | }, {}), 40 | }, 41 | }; 42 | 43 | module.exports = config; 44 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "main": "node_modules/expo/AppEntry.js", 5 | "scripts": { 6 | "start": "expo start", 7 | "android": "expo start --android", 8 | "ios": "expo start --ios", 9 | "web": "expo start --web" 10 | }, 11 | "dependencies": { 12 | "expo": "~49.0.7", 13 | "expo-status-bar": "~1.6.0", 14 | "react": "18.2.0", 15 | "react-native": "0.72.3", 16 | "react-dom": "18.2.0", 17 | "react-native-web": "~0.19.6" 18 | }, 19 | "devDependencies": { 20 | "@babel/core": "^7.20.0", 21 | "babel-plugin-module-resolver": "^5.0.0", 22 | "@expo/webpack-config": "^18.0.1", 23 | "babel-loader": "^8.1.0" 24 | }, 25 | "private": true 26 | } -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { View, StyleSheet, Text } from 'react-native'; 3 | import OutsidePressHandler, { EventProvider } from 'react-native-outside-press'; 4 | 5 | export default function App() { 6 | return ( 7 | 8 | { 10 | console.log('Pressed outside the black box!'); 11 | }} 12 | > 13 | 14 | console.log('Pressed inside blackbox!')} 17 | > 18 | Press Me 19 | 20 | 21 | 22 | console.log('Pressed!')}>Press Me 23 | { 25 | console.log('Pressed outside the red box!'); 26 | }} 27 | > 28 | 29 | 30 | 31 | ); 32 | } 33 | 34 | const styles = StyleSheet.create({ 35 | container: { 36 | flex: 1, 37 | }, 38 | blackBox: { 39 | height: 200, 40 | backgroundColor: 'black', 41 | }, 42 | redBox: { 43 | height: 200, 44 | backgroundColor: 'red', 45 | }, 46 | text: { 47 | color: 'white', 48 | }, 49 | }); 50 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig", 3 | "compilerOptions": { 4 | // Avoid expo-cli auto-generating a tsconfig 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /example/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const createExpoWebpackConfigAsync = require('@expo/webpack-config'); 3 | const { resolver } = require('./metro.config'); 4 | 5 | const root = path.resolve(__dirname, '..'); 6 | const node_modules = path.join(__dirname, 'node_modules'); 7 | 8 | module.exports = async function (env, argv) { 9 | const config = await createExpoWebpackConfigAsync(env, argv); 10 | 11 | config.module.rules.push({ 12 | test: /\.(js|jsx|ts|tsx)$/, 13 | include: path.resolve(root, 'src'), 14 | use: 'babel-loader', 15 | }); 16 | 17 | // We need to make sure that only one version is loaded for peerDependencies 18 | // So we alias them to the versions in example's node_modules 19 | Object.assign(config.resolve.alias, { 20 | ...resolver.extraNodeModules, 21 | 'react-native-web': path.join(node_modules, 'react-native-web'), 22 | }); 23 | 24 | return config; 25 | }; 26 | -------------------------------------------------------------------------------- /lefthook.yml: -------------------------------------------------------------------------------- 1 | pre-commit: 2 | parallel: true 3 | commands: 4 | lint: 5 | files: git diff --name-only @{push} 6 | glob: "*.{js,ts,jsx,tsx}" 7 | run: npx eslint {files} 8 | types: 9 | files: git diff --name-only @{push} 10 | glob: "*.{js,ts, jsx, tsx}" 11 | run: npx tsc --noEmit 12 | commit-msg: 13 | parallel: true 14 | commands: 15 | commitlint: 16 | run: npx commitlint --edit 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-outside-press", 3 | "version": "1.2.2", 4 | "description": "airbnb/react-outside-click-handler but for React Native", 5 | "main": "lib/commonjs/index", 6 | "module": "lib/module/index", 7 | "types": "lib/typescript/index.d.ts", 8 | "react-native": "src/index", 9 | "source": "src/index", 10 | "files": [ 11 | "src", 12 | "lib", 13 | "android", 14 | "ios", 15 | "cpp", 16 | "*.podspec", 17 | "!lib/typescript/example", 18 | "!ios/build", 19 | "!android/build", 20 | "!android/gradle", 21 | "!android/gradlew", 22 | "!android/gradlew.bat", 23 | "!android/local.properties", 24 | "!**/__tests__", 25 | "!**/__fixtures__", 26 | "!**/__mocks__", 27 | "!**/.*" 28 | ], 29 | "scripts": { 30 | "typecheck": "tsc --noEmit", 31 | "lint": "eslint \"**/*.{js,ts,tsx}\"", 32 | "prepack": "bob build", 33 | "example": "yarn --cwd example", 34 | "bootstrap": "yarn example && yarn install" 35 | }, 36 | "keywords": [ 37 | "react-native", 38 | "ios", 39 | "android" 40 | ], 41 | "repository": "https://github.com/dcangulo/react-native-outside-press", 42 | "author": "David Angulo (https://www.davidangulo.xyz)", 43 | "license": "MIT", 44 | "bugs": { 45 | "url": "https://github.com/dcangulo/react-native-outside-press/issues" 46 | }, 47 | "homepage": "https://github.com/dcangulo/react-native-outside-press#readme", 48 | "devDependencies": { 49 | "@commitlint/config-conventional": "^18.0.0", 50 | "@evilmartians/lefthook": "^1.4.8", 51 | "@react-native-community/eslint-config": "^3.0.2", 52 | "@types/react": "~18.2.20", 53 | "@types/react-native": "0.72.6", 54 | "commitlint": "^18.0.0", 55 | "del-cli": "^5.0.0", 56 | "eslint": "^8.4.1", 57 | "eslint-config-prettier": "^9.0.0", 58 | "eslint-plugin-prettier": "^4.0.0", 59 | "pod-install": "^0.1.0", 60 | "prettier": "^2.0.5", 61 | "react": "18.2.0", 62 | "react-native": "0.72.6", 63 | "react-native-builder-bob": "^0.23.1", 64 | "typescript": "^5.0.2" 65 | }, 66 | "resolutions": { 67 | "@types/react": "17.0.21" 68 | }, 69 | "peerDependencies": { 70 | "react": "*", 71 | "react-native": "*" 72 | }, 73 | "commitlint": { 74 | "extends": [ 75 | "@commitlint/config-conventional" 76 | ] 77 | }, 78 | "eslintConfig": { 79 | "root": true, 80 | "extends": [ 81 | "@react-native-community", 82 | "prettier" 83 | ], 84 | "rules": { 85 | "prettier/prettier": [ 86 | "error", 87 | { 88 | "quoteProps": "consistent", 89 | "singleQuote": true, 90 | "tabWidth": 2, 91 | "trailingComma": "es5", 92 | "useTabs": false 93 | } 94 | ], 95 | "react-hooks/exhaustive-deps": 0 96 | } 97 | }, 98 | "eslintIgnore": [ 99 | "node_modules/", 100 | "lib/" 101 | ], 102 | "prettier": { 103 | "quoteProps": "consistent", 104 | "singleQuote": true, 105 | "tabWidth": 2, 106 | "trailingComma": "es5", 107 | "useTabs": false 108 | }, 109 | "react-native-builder-bob": { 110 | "source": "src", 111 | "output": "lib", 112 | "targets": [ 113 | "commonjs", 114 | "module", 115 | [ 116 | "typescript", 117 | { 118 | "project": "tsconfig.build.json" 119 | } 120 | ] 121 | ] 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /scripts/bootstrap.js: -------------------------------------------------------------------------------- 1 | const os = require('os'); 2 | const path = require('path'); 3 | const child_process = require('child_process'); 4 | 5 | const root = path.resolve(__dirname, '..'); 6 | const args = process.argv.slice(2); 7 | const options = { 8 | cwd: process.cwd(), 9 | env: process.env, 10 | stdio: 'inherit', 11 | encoding: 'utf-8', 12 | }; 13 | 14 | if (os.type() === 'Windows_NT') { 15 | options.shell = true; 16 | } 17 | 18 | let result; 19 | 20 | if (process.cwd() !== root || args.length) { 21 | // We're not in the root of the project, or additional arguments were passed 22 | // In this case, forward the command to `yarn` 23 | result = child_process.spawnSync('yarn', args, options); 24 | } else { 25 | // If `yarn` is run without arguments, perform bootstrap 26 | result = child_process.spawnSync('yarn', ['bootstrap'], options); 27 | } 28 | 29 | process.exitCode = result.status; 30 | -------------------------------------------------------------------------------- /src/components/container.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { Platform, View } from 'react-native'; 3 | import type { ViewProps } from 'react-native'; 4 | import useEvent from '../hooks/use-event'; 5 | import type { IEvent } from '../hooks/use-event-store'; 6 | import deepClone from '../utils/deep-clone'; 7 | 8 | export default function Container(props: ViewProps) { 9 | const { events, skippedEventId, setSkippedEventId } = useEvent(); 10 | const runEvents = () => { 11 | events.forEach((event: IEvent) => { 12 | if (event.id === (global as any).rnopSkippedEventId) return; 13 | if (event.disabled) return; 14 | 15 | event.onOutsidePress(); 16 | }); 17 | 18 | if ((global as any).rnopSkippedEventId) setSkippedEventId(''); 19 | }; 20 | 21 | useEffect(() => { 22 | if (skippedEventId) runEvents(); 23 | }, [skippedEventId]); 24 | 25 | return Platform.select({ 26 | web: ( 27 | 33 | {deepClone(props.children, runEvents)} 34 | 35 | ), 36 | default: ( 37 | 38 | {props.children} 39 | 40 | ), 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /src/components/event-provider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { StyleSheet } from 'react-native'; 3 | import type { ViewProps } from 'react-native'; 4 | import useEventStore from '../hooks/use-event-store'; 5 | import EventContext from '../event-context'; 6 | import Container from './container'; 7 | 8 | export default function EventProvider(props: ViewProps) { 9 | const { style, ...rest } = props; 10 | const eventStore = useEventStore(); 11 | 12 | return ( 13 | 14 | 15 | {props.children} 16 | 17 | 18 | ); 19 | } 20 | 21 | const styles = StyleSheet.create({ 22 | container: { 23 | flex: 1, 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /src/components/outside-press-handler.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect } from 'react'; 2 | import { View, Platform } from 'react-native'; 3 | import type { ViewProps } from 'react-native'; 4 | import useEvent from '../hooks/use-event'; 5 | import deepClone from '../utils/deep-clone'; 6 | 7 | interface IOutsidePressHandlerProps extends ViewProps { 8 | onOutsidePress: () => void; 9 | disabled?: boolean; 10 | } 11 | 12 | export default function OutsidePressHandler(props: IOutsidePressHandlerProps) { 13 | const { children, onOutsidePress, disabled = false } = props; 14 | const id: string = useRef(Math.random().toString()).current; 15 | const { appendEvent, removeEvent, setSkippedEventId } = useEvent(); 16 | const setSkippedEventIdFunc = () => setSkippedEventId(id); 17 | 18 | useEffect(() => { 19 | appendEvent({ id, onOutsidePress, disabled }); 20 | 21 | return () => removeEvent(id); 22 | }, [onOutsidePress, disabled]); 23 | 24 | return Platform.select({ 25 | web: ( 26 | 32 | {deepClone(children, setSkippedEventIdFunc)} 33 | 34 | ), 35 | default: ( 36 | 37 | {children} 38 | 39 | ), 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /src/event-context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import type { IEvent } from './hooks/use-event-store'; 3 | 4 | export type EventContextType = { 5 | events: IEvent[]; 6 | appendEvent: (newEvent: IEvent) => void; 7 | removeEvent: (id: string) => void; 8 | skippedEventId: string; 9 | setSkippedEventId: (id: string) => void; 10 | }; 11 | 12 | const EventContext = createContext(null); 13 | 14 | export default EventContext; 15 | -------------------------------------------------------------------------------- /src/hooks/use-event-store.ts: -------------------------------------------------------------------------------- 1 | import { useState, useMemo } from 'react'; 2 | 3 | export interface IEvent { 4 | id: string; 5 | onOutsidePress: () => void; 6 | disabled: boolean; 7 | } 8 | 9 | export default function useEventStore() { 10 | const [events, setEvents] = useState([]); 11 | const [skippedEventId, setSkippedEventId] = useState(''); 12 | const eventActions = useMemo( 13 | () => ({ 14 | events, 15 | appendEvent: (newEvent: IEvent) => 16 | setEvents((state) => [...state, newEvent]), 17 | removeEvent: (id: string) => 18 | setEvents((state) => state.filter((event) => event.id !== id)), 19 | skippedEventId, 20 | setSkippedEventId: (id: string) => { 21 | (global as any).rnopSkippedEventId = id; 22 | setSkippedEventId(id); 23 | }, 24 | }), 25 | [skippedEventId, events] 26 | ); 27 | 28 | return eventActions; 29 | } 30 | -------------------------------------------------------------------------------- /src/hooks/use-event.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import EventContext from '../event-context'; 3 | import type { EventContextType } from '../event-context'; 4 | 5 | export default function useEvent() { 6 | const eventContext = useContext(EventContext) as EventContextType; 7 | 8 | return eventContext; 9 | } 10 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import EventProvider from './components/event-provider'; 2 | import OutsidePressHandler from './components/outside-press-handler'; 3 | 4 | export { EventProvider }; 5 | export default OutsidePressHandler; 6 | -------------------------------------------------------------------------------- /src/utils/deep-clone.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function deepClone(children: React.ReactNode, func: () => void) { 4 | return React.Children.map( 5 | children, 6 | (child: React.ReactNode): React.ReactNode => { 7 | if (!React.isValidElement(child)) return child; 8 | 9 | const props: any = { ...child.props }; 10 | 11 | if (typeof child.props.onPress === 'function') { 12 | props.onPress = () => { 13 | func(); 14 | return child.props.onPress(); 15 | }; 16 | } 17 | 18 | return React.cloneElement( 19 | child, 20 | props, 21 | deepClone(child.props.children, func) 22 | ); 23 | } 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "extends": "./tsconfig", 4 | "exclude": ["example"] 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "paths": { 5 | "react-native-outside-press": ["./src/index"] 6 | }, 7 | "allowUnreachableCode": false, 8 | "allowUnusedLabels": false, 9 | "esModuleInterop": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "jsx": "react", 12 | "lib": ["esnext"], 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "noFallthroughCasesInSwitch": true, 16 | "noImplicitReturns": true, 17 | "noImplicitUseStrict": false, 18 | "noStrictGenericChecks": false, 19 | "noUncheckedIndexedAccess": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "resolveJsonModule": true, 23 | "skipLibCheck": true, 24 | "strict": true, 25 | "target": "esnext", 26 | "verbatimModuleSyntax": true 27 | } 28 | } 29 | --------------------------------------------------------------------------------