├── .gitignore ├── .prettierrc ├── .storybook ├── addons.js ├── config.js ├── preview-head.html └── webpack.config.js ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── package.json ├── src └── index.tsx ├── stories ├── TestElements.ts ├── complex-example │ ├── Tooltip.tsx │ └── complex.stories.tsx ├── index.stories.tsx └── render-prop.stories.tsx ├── tests └── e2e │ ├── ClicksPage.ts │ └── clicks.test.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | /lib 35 | /storybook-static 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # Typescript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- 1 | import '@storybook/addon-actions/register'; 2 | import '@storybook/addon-links/register'; 3 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure } from "@storybook/react"; 2 | 3 | const req = require.context("../stories", true, /.stories.tsx?$/); 4 | function loadStories() { 5 | req.keys().forEach(filename => req(filename)); 6 | } 7 | 8 | configure(loadStories, module); 9 | -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = (baseConfig, env, config) => { 4 | config.module.rules.push({ 5 | test: /\.(ts|tsx)$/, 6 | loader: require.resolve("ts-loader") 7 | }); 8 | 9 | config.resolve.extensions.push(".ts", ".tsx"); 10 | return config; 11 | }; 12 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at bboytx@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Timur Khazamov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-foco 2 | 3 | React component for handling clicks and focuses outside 4 | 5 | - Handles clicks and **focuses** outside 6 | - Takes care of children rendered in **react portals** 7 | - **Small** — less then 600b minified and gzipped, has no dependencies 8 | - TypeScript friendly 9 | 10 | ```javascript 11 | import Foco from 'react-foco'; 12 | 13 | const MyComponent3000 = () => ( 14 | console.log('Click Outside')} 16 | onFocusOutside={() => console.log('Focus Outside')} 17 | > 18 | 19 | {React.createPortal(, portalNode)} 20 | 21 | ); 22 | ``` 23 | 24 | [Demo](https://codesandbox.io/s/wy8n9koxo7) 25 | 26 | ## Changelog 27 | 28 | https://github.com/nanot1m/react-foco/releases 29 | 30 | ## API 31 | 32 | ### Props 33 | 34 | - `onClickOutside` — function called on clicks outside of wrapping nodes 35 | - `onFocusOutside` — function called on focus outside of wrapping nodes 36 | - `render` — prop allows for inline rendering foco content 37 | - `className` — class passed to wrapping node 38 | - `style` — object with css properties passed to wrapping node 39 | - `children` — children react elements or function the same as prop `render` 40 | - `component` — component or tag which is used to render wrapper node 41 | 42 | ### Render Props 43 | 44 | This prop are passed for callback in props render or children 45 | 46 | - `className?: string` — class name provided from Foco component 47 | - `style?: React.CSSProperties` — styles provided from Foco component 48 | - `onMouseDown: React.MouseEventHandler` — handler for checking clicks outside 49 | - `onFocus: React.FocusEventHandler` — handler for checking focuses outside 50 | - `onTouchStart: React.TouchEventHandler` — handler for checking touches outside 51 | 52 | ### Render-prop example 53 | 54 | ```jsx 55 | function MyComponent() { 56 | return ( 57 | alert('click out')}> 58 | {wrapperProps => ( 59 |
63 |

Hola! Clicks outside would trigger alerts

64 |
65 | )} 66 |
67 | ); 68 | } 69 | ``` 70 | 71 | ## Development 72 | 73 | ### Tests 74 | 75 | - run storybook: `yarn storybook` 76 | - run tests: `yarn test` 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-foco", 3 | "version": "1.3.1", 4 | "description": "React component for handling clicks and focuses outside, which works with portals", 5 | "main": "lib/index.js", 6 | "files": [ 7 | "lib/index.js", 8 | "lib/index.d.ts" 9 | ], 10 | "repository": "git@github.com:nanot1m/react-foco.git", 11 | "author": "Timur Khazamov ", 12 | "license": "MIT", 13 | "size-limit": [ 14 | { 15 | "size": "1 KB", 16 | "path": "lib/index.js" 17 | } 18 | ], 19 | "scripts": { 20 | "build-storybook": "build-storybook", 21 | "build": "rimraf lib && tsc", 22 | "deploy": "gh-pages -d storybook-static", 23 | "precommit": "lint-staged", 24 | "predeploy": "build-storybook", 25 | "prepublishOnly": "npm run build", 26 | "size": "size-limit", 27 | "storybook": "start-storybook -p 6006", 28 | "test": "wait-on http-get://localhost:6006 && jest" 29 | }, 30 | "devDependencies": { 31 | "@storybook/addon-actions": "^3.4.3", 32 | "@storybook/addon-links": "^3.4.3", 33 | "@storybook/addons": "^3.4.3", 34 | "@storybook/react": "^3.4.3", 35 | "@types/jest": "^22.2.3", 36 | "@types/node": "8.5.5", 37 | "@types/puppeteer": "^1.2.3", 38 | "@types/react": "^16.3.13", 39 | "@types/react-dom": "^16.0.5", 40 | "@types/storybook__react": "^3.0.7", 41 | "babel-core": "^6.26.3", 42 | "babel-runtime": "^6.26.0", 43 | "gh-pages": "^1.1.0", 44 | "husky": "^0.14.3", 45 | "jest": "^22.4.3", 46 | "lint-staged": "^7.0.5", 47 | "prettier": "^1.12.1", 48 | "puppeteer": "^1.3.0", 49 | "react": "^16.3.2", 50 | "react-dom": "^16.3.2", 51 | "rimraf": "^2.6.2", 52 | "size-limit": "^0.21.1", 53 | "ts-jest": "^22.4.4", 54 | "ts-loader": "3.5.0", 55 | "typescript": "^3.2.4", 56 | "wait-on": "^2.1.0" 57 | }, 58 | "peerDependencies": { 59 | "react": ">16.0.0", 60 | "react-dom": ">16.0.0" 61 | }, 62 | "dependencies": {}, 63 | "lint-staged": { 64 | "*.{ts,tsx}": [ 65 | "prettier --single-quote --write", 66 | "git add" 67 | ] 68 | }, 69 | "keywords": [ 70 | "react", 71 | "clickoutside", 72 | "focusoutside", 73 | "outside", 74 | "portal", 75 | "createPortal" 76 | ], 77 | "jest": { 78 | "testURL": "http://localhost", 79 | "transform": { 80 | "^.+\\.tsx?$": "ts-jest" 81 | }, 82 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", 83 | "moduleFileExtensions": [ 84 | "ts", 85 | "tsx", 86 | "js", 87 | "jsx", 88 | "json", 89 | "node" 90 | ] 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export interface FocoRenderProps { 4 | className?: string; 5 | style?: React.CSSProperties; 6 | onMouseDown: React.MouseEventHandler; 7 | onFocus: React.FocusEventHandler; 8 | onTouchStart: React.TouchEventHandler; 9 | } 10 | 11 | export type FocoRenderType = (props: FocoRenderProps) => React.ReactNode; 12 | 13 | export interface FocoProps { 14 | onClickOutside?: (event: MouseEvent | TouchEvent) => void; 15 | onFocusOutside?: (event: FocusEvent) => void; 16 | children?: FocoRenderType | React.ReactNode; 17 | className?: string; 18 | style?: React.CSSProperties; 19 | component?: any; 20 | render?: FocoRenderType; 21 | } 22 | 23 | export default class Foco extends React.Component { 24 | private clickCaptured: boolean = false; 25 | private focusCaptured: boolean = false; 26 | 27 | public componentDidMount() { 28 | this.init(); 29 | } 30 | 31 | public componentWillUnmount() { 32 | this.flush(); 33 | } 34 | 35 | public render() { 36 | const render = this.props.render || this.props.children; 37 | 38 | if (typeof render === 'function') { 39 | return render(this.getProps()); 40 | } 41 | 42 | return this.renderComponent(); 43 | } 44 | 45 | private renderComponent() { 46 | return React.createElement( 47 | this.props.component || 'span', 48 | this.getProps(), 49 | this.props.children 50 | ); 51 | } 52 | 53 | private getProps(): FocoRenderProps { 54 | return { 55 | className: this.props.className, 56 | style: this.props.style, 57 | onMouseDown: this.innerClick, 58 | onFocus: this.innerFocus, 59 | onTouchStart: this.innerClick 60 | }; 61 | } 62 | 63 | private init() { 64 | document.addEventListener('mousedown', this.documentClick); 65 | document.addEventListener('focus', this.documentFocus, true); 66 | document.addEventListener('touchstart', this.documentClick); 67 | } 68 | 69 | private flush() { 70 | document.removeEventListener('mousedown', this.documentClick); 71 | document.removeEventListener('focus', this.documentFocus, true); 72 | document.removeEventListener('touchstart', this.documentClick); 73 | } 74 | 75 | private documentClick = (event: MouseEvent | TouchEvent) => { 76 | if (!this.clickCaptured && this.props.onClickOutside) { 77 | this.props.onClickOutside(event); 78 | } 79 | this.clickCaptured = false; 80 | }; 81 | 82 | private innerClick = () => { 83 | this.clickCaptured = true; 84 | }; 85 | 86 | private documentFocus = (event: FocusEvent) => { 87 | if (!this.focusCaptured && this.props.onFocusOutside) { 88 | this.props.onFocusOutside(event); 89 | } 90 | this.focusCaptured = false; 91 | }; 92 | 93 | private innerFocus = () => { 94 | this.focusCaptured = true; 95 | }; 96 | } 97 | -------------------------------------------------------------------------------- /stories/TestElements.ts: -------------------------------------------------------------------------------- 1 | export class TestElements { 2 | public static readonly ClickStatusNode: string = 'ClickStatusNode'; 3 | public static readonly FocusStatusNode: string = 'FocusStatusNode'; 4 | public static readonly InnerNode: string = 'Inner'; 5 | public static readonly OuterNode: string = 'Outer'; 6 | } 7 | -------------------------------------------------------------------------------- /stories/complex-example/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createPortal } from 'react-dom'; 3 | 4 | export interface TooltipProps { 5 | children: React.ReactNode; 6 | anchor: HTMLElement; 7 | } 8 | 9 | export class Tooltip extends React.Component { 10 | private domNode: HTMLDivElement; 11 | 12 | constructor(props: TooltipProps) { 13 | super(props); 14 | this.domNode = document.createElement('div'); 15 | } 16 | 17 | componentDidMount() { 18 | document.body.appendChild(this.domNode); 19 | } 20 | 21 | componentWillUnmount() { 22 | document.body.removeChild(this.domNode); 23 | } 24 | 25 | public render() { 26 | const pos = getTooltipPosition(this.props.anchor, -12); 27 | const pin = ( 28 |
40 | ); 41 | const tooltip = ( 42 |
52 | {this.props.children} 53 | {pin} 54 |
55 | ); 56 | return createPortal(tooltip, this.domNode); 57 | } 58 | } 59 | 60 | function getTooltipPosition(node: HTMLElement, yOffset: number = 0) { 61 | const docHeight = document.documentElement.clientHeight; 62 | const rect = node.getBoundingClientRect(); 63 | return { 64 | bottom: docHeight - rect.top - yOffset, 65 | left: rect.left 66 | }; 67 | } 68 | -------------------------------------------------------------------------------- /stories/complex-example/complex.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { storiesOf } from '@storybook/react'; 4 | 5 | import Foco from '../../src'; 6 | import { Tooltip } from './Tooltip'; 7 | 8 | storiesOf('Tooltip', module) 9 | .add('sample', () => ) 10 | .add('with_scroll', () => ); 11 | 12 | const DemoScene = ({ count = 0 }: { count: number }) => ( 13 | <> 14 |

User List

15 |
16 |
26 |
27 | {getUsers(count).map((x, i) => )} 28 |
29 |
30 |
31 |

32 | User list has overflow-y: scroll and{' '} 33 | overflow-x: hidden. Tooltips are rendered as last child 34 | of body, with ReactDOM.createPortal api. Other way they will be 35 | hidden in case of overflow 36 |

37 |

38 | Foco component handles clicks and focuses outside, taking care 39 | of portals 40 |

41 |
42 |
43 | 44 | ); 45 | 46 | interface ItemProps { 47 | name: string; 48 | } 49 | 50 | interface ItemState { 51 | anchor: HTMLElement | null; 52 | } 53 | 54 | class Item extends React.Component { 55 | public state: ItemState = { 56 | anchor: null 57 | }; 58 | 59 | public render() { 60 | return ( 61 |
70 | {this.props.name} 71 | 75 | 76 | {this.state.anchor && ( 77 | 78 | 81 | 82 | )} 83 | 84 |
85 | ); 86 | } 87 | 88 | private closeTooltip = () => { 89 | return this.setState({ anchor: null }); 90 | }; 91 | 92 | private handleClick = (event: React.MouseEvent) => { 93 | const target = event.target as HTMLButtonElement; 94 | this.setState(state => { 95 | if (state.anchor) { 96 | return { anchor: null }; 97 | } 98 | return { anchor: target }; 99 | }); 100 | }; 101 | } 102 | 103 | function getUsers(count: number) { 104 | const Names: string[] = [ 105 | 'Chasidy', 106 | 'Remedios', 107 | 'Warner', 108 | 'Spencer', 109 | 'Malissa', 110 | 'Benton', 111 | 'Jeni', 112 | 'Jeremiah', 113 | 'Kori', 114 | 'Mardell', 115 | 'Marcelina', 116 | 'Rosamond', 117 | 'Lorriane', 118 | 'Artie', 119 | 'Bunny', 120 | 'Loren', 121 | 'Jame', 122 | 'Sonny', 123 | 'Keith', 124 | 'Jon' 125 | ]; 126 | const Surnames: string[] = [ 127 | 'Sherrard', 128 | 'Rustin', 129 | 'Schomer', 130 | 'Alvares', 131 | 'Sindelar', 132 | 'Vanarsdale', 133 | 'Wrobel', 134 | 'Tolman', 135 | 'Ellerman', 136 | 'Ayala', 137 | 'Comes', 138 | 'Bungard', 139 | 'Vine', 140 | 'Markus', 141 | 'Light', 142 | 'Tait', 143 | 'Batt', 144 | 'Steedley', 145 | 'Calico', 146 | 'Boissonneault' 147 | ]; 148 | 149 | const getRandName = () => Names[Math.floor(Math.random() * Names.length)]; 150 | const getRandSurname = () => 151 | Surnames[Math.floor(Math.random() * Surnames.length)]; 152 | 153 | return Array(count) 154 | .fill(0) 155 | .map((_: any, index: number) => `${getRandName()} ${getRandSurname()}`); 156 | } 157 | -------------------------------------------------------------------------------- /stories/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { storiesOf } from '@storybook/react'; 4 | 5 | import Foco from '../src'; 6 | import { TestElements } from './TestElements'; 7 | 8 | storiesOf('Foco', module).add('clicks_and_focuses', () => ); 9 | 10 | export enum ComponentActions { 11 | None = 'None', 12 | ClickInside = 'ClickInside', 13 | ClickOutside = 'ClickOutside', 14 | FocusInside = 'FocusInside', 15 | FocusOutside = 'FocusOutside' 16 | } 17 | 18 | class DemoComponent extends React.Component { 19 | public state = { 20 | clickStatus: ComponentActions.None, 21 | focusStatus: ComponentActions.None 22 | }; 23 | 24 | render() { 25 | return ( 26 | <> 27 | { 29 | this.setState({ clickStatus: ComponentActions.ClickOutside }); 30 | }} 31 | onFocusOutside={() => { 32 | this.setState({ focusStatus: ComponentActions.FocusOutside }); 33 | }} 34 | > 35 |
38 | this.setState({ clickStatus: ComponentActions.ClickInside }) 39 | } 40 | onFocus={() => 41 | this.setState({ focusStatus: ComponentActions.FocusInside }) 42 | } 43 | style={{ backgroundColor: 'hotpink', color: 'white', padding: 40 }} 44 | > 45 | 46 |
47 | Click status:{' '} 48 | 49 | {this.state.clickStatus} 50 | 51 |
52 |
53 | Focus status:{' '} 54 | 55 | {this.state.focusStatus} 56 | 57 |
58 |
59 |
60 |
61 | 62 |
63 | 64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /stories/render-prop.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { storiesOf } from '@storybook/react'; 4 | 5 | import Foco from '../src'; 6 | 7 | storiesOf('Render Prop', module) 8 | .add('prop render', () => { 9 | return ( 10 | alert('click out')} 12 | render={wrapperProps => ( 13 |
17 |

18 | Hola! Render prop is provided. Clicks outside would trigger alerts 19 |

20 |
21 | )} 22 | /> 23 | ); 24 | }) 25 | .add('prop children', () => { 26 | return ( 27 | alert('click out')}> 28 | {wrapperProps => ( 29 |
33 |

34 | Hola! Function as children prop is provided. Clicks outside would 35 | trigger alerts 36 |

37 |
38 | )} 39 |
40 | ); 41 | }) 42 | .add('prop component', () => { 43 | return ( 44 | alert('click out')} 46 | component="div" 47 | style={{ border: '1px solid skyblue', textAlign: 'center' }} 48 | > 49 |

50 | Hola! Component prop is provided. Clicks outside would trigger alerts 51 |

52 |
53 | ); 54 | }); 55 | -------------------------------------------------------------------------------- /tests/e2e/ClicksPage.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'puppeteer'; 2 | import { TestElements } from '../../stories/TestElements'; 3 | 4 | export class ClicksPage { 5 | constructor(private page: Page) {} 6 | 7 | public async open() { 8 | await this.page.goto( 9 | 'http://localhost:6006/iframe.html?selectedKind=Foco&selectedStory=clicks_and_focuses' 10 | ); 11 | } 12 | 13 | public getClicksStatus(): Promise { 14 | return this.page.$eval( 15 | getSelectorByTestID(TestElements.ClickStatusNode), 16 | node => node.textContent 17 | ); 18 | } 19 | 20 | public getFocusStatus(): Promise { 21 | return this.page.$eval( 22 | getSelectorByTestID(TestElements.FocusStatusNode), 23 | node => node.textContent 24 | ); 25 | } 26 | 27 | public clickInside() { 28 | return this.page.click( 29 | `${getSelectorByTestID(TestElements.InnerNode)} button` 30 | ); 31 | } 32 | 33 | public clickOutside() { 34 | return this.page.click( 35 | `${getSelectorByTestID(TestElements.OuterNode)} button` 36 | ); 37 | } 38 | 39 | public focusInside() { 40 | return this.page.focus( 41 | `${getSelectorByTestID(TestElements.InnerNode)} button` 42 | ); 43 | } 44 | 45 | public focusOutside() { 46 | return this.page.focus( 47 | `${getSelectorByTestID(TestElements.OuterNode)} button` 48 | ); 49 | } 50 | } 51 | 52 | function getSelectorByTestID(testID: string) { 53 | return `[data-testID=${testID}]`; 54 | } 55 | -------------------------------------------------------------------------------- /tests/e2e/clicks.test.ts: -------------------------------------------------------------------------------- 1 | import puppeteer, { Browser } from 'puppeteer'; 2 | import { ClicksPage } from './ClicksPage'; 3 | import { ComponentActions } from '../../stories/index.stories'; 4 | 5 | let browser: Browser; 6 | let clicksPage: ClicksPage; 7 | 8 | beforeAll(async () => { 9 | browser = await puppeteer.launch(); 10 | let page = await browser.newPage(); 11 | clicksPage = new ClicksPage(page); 12 | await clicksPage.open(); 13 | }); 14 | 15 | afterAll(async () => { 16 | await browser.close(); 17 | }); 18 | 19 | test('default status', async () => { 20 | expect(await clicksPage.getClicksStatus()).toBe(ComponentActions.None); 21 | }); 22 | 23 | test('click outside', async () => { 24 | await clicksPage.clickOutside(); 25 | expect(await clicksPage.getClicksStatus()).toBe( 26 | ComponentActions.ClickOutside 27 | ); 28 | }); 29 | 30 | test('click inside', async () => { 31 | await clicksPage.clickInside(); 32 | expect(await clicksPage.getClicksStatus()).toBe(ComponentActions.ClickInside); 33 | }); 34 | 35 | test('focus outside', async () => { 36 | await clicksPage.focusOutside(); 37 | expect(await clicksPage.getFocusStatus()).toBe(ComponentActions.FocusOutside); 38 | }); 39 | 40 | test('focus inside', async () => { 41 | await clicksPage.focusInside(); 42 | expect(await clicksPage.getFocusStatus()).toBe(ComponentActions.FocusInside); 43 | }); 44 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "lib": ["dom", "es2015"], 6 | "jsx": "react", 7 | "declaration": true, 8 | "outDir": "./lib", 9 | "strict": true, 10 | "esModuleInterop": true 11 | }, 12 | "include": ["./src"] 13 | } 14 | --------------------------------------------------------------------------------