├── .editorconfig ├── .gitignore ├── .prettierrc ├── .storybook ├── addons.js ├── config.js └── webpack.config.js ├── .vscode └── settings.json ├── README.md ├── README └── storybook.png ├── bili.config.ts ├── package.json ├── src ├── __stories__ │ └── layout.stories.tsx ├── __tests__ │ ├── __snapshots__ │ │ └── index.spec.tsx.snap │ └── index.spec.tsx ├── context.tsx ├── hooks.ts ├── index.ts ├── layout.tsx └── types.ts ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | charset = utf-8 11 | 12 | # CSS - https://developer.mozilla.org/ja/docs/Web/CSS 13 | # Sass(SCSS) - http://sass-lang.com/ 14 | # Stylus - https://learnboost.github.io/stylus/ 15 | # PostCSS - https://postcss.org/ 16 | [*.{css,scss,sass,styl,pcss}] 17 | indent_style = space 18 | indent_size = 2 19 | trim_trailing_whitespace = true 20 | 21 | # Handlebars.js - http://handlebarsjs.com/ 22 | [*.hbs] 23 | indent_style = space 24 | indent_size = 2 25 | 26 | # JavaScript - https://developer.mozilla.org/ja/docs/Web/JavaScript 27 | # TypeScript - https://www.typescriptlang.org/ 28 | # React - https://reactjs.org/ 29 | # Vue - https://vuejs.org/ 30 | [*.{js,ts,jsx,tsx,vue}] 31 | indent_style = space 32 | indent_size = 2 33 | 34 | # JSON - http://json.org/ 35 | # Composer - https://getcomposer.org/doc/04-schema.md 36 | [{*.json,composer.lock}] 37 | indent_style = space 38 | indent_size = 4 39 | 40 | # NPM - https://docs.npmjs.com/files/package.json 41 | # TypeScript - https://www.typescriptlang.org/ 42 | # Stylint - https://rosspatton.github.io/stylint/ 43 | # prettier - https://prettier.io/ 44 | # ESlint - https://eslint.org/ 45 | # VSCode Settings - https://code.visualstudio.com/docs/getstarted/settings 46 | [{package.json,tsconfig.json,.stylintrc,.prettierrc,.eslintrc,.vscode/settings.json}] 47 | indent_style = space 48 | indent_size = 2 49 | 50 | # PHP - http://php.net/ 51 | [*.php] 52 | indent_style = space 53 | indent_size = 4 54 | 55 | # Python - https://www.python.org/ 56 | [*.py] 57 | indent_style = space 58 | indent_size = 4 59 | 60 | # Ruby - https://www.ruby-lang.org/ 61 | # Rake - https://github.com/ruby/rake 62 | [{*.rb,*.rake,Rakefile}] 63 | indent_style = space 64 | indent_size = 2 65 | 66 | # Shell script (bash) - https://www.gnu.org/software/bash/manual/bash.html 67 | [*.sh] 68 | indent_style = space 69 | indent_size = 4 70 | 71 | # SQL (MySQL) - https://www.mysql.com/ 72 | [*.sql] 73 | indent_style = space 74 | indent_size = 2 75 | 76 | # Smarty 2 -http://www.smarty.net/docsv2/en/ 77 | [*.tpl] 78 | indent_style = space 79 | indent_size = 4 80 | 81 | # XHTML - http://www.w3.org/TR/xhtml1/ 82 | [*.xhtml] 83 | indent_style = space 84 | indent_size = 4 85 | 86 | # YAML - http://yaml.org/ 87 | [{*.yml,*.yaml}] 88 | indent_style = space 89 | indent_size = 2 90 | 91 | # p(ixi)v 92 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | yarn-error.log 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false 3 | } 4 | -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- 1 | import "@storybook/addon-actions/register" 2 | import "@storybook/addon-knobs/register" 3 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure } from "@storybook/react" 2 | 3 | // automatically import all files ending in *.stories.tsx 4 | configure(require.context("..", true, /\.stories\.tsx$/), module) 5 | -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | 3 | /** @type import('webpack').Configuration */ 4 | module.exports = { 5 | context: path.resolve(__dirname, ".."), 6 | resolve: { 7 | extensions: [".js", ".jsx", ".ts", ".tsx"] 8 | }, 9 | module: { 10 | rules: [ 11 | { 12 | test: /\.tsx?$/, 13 | use: [ 14 | { 15 | loader: "ts-loader", 16 | options: { 17 | configFile: path.resolve(__dirname, "..", "tsconfig.json"), 18 | transpileOnly: true 19 | } 20 | } 21 | ] 22 | } 23 | ] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "typescript.tsdk": "node_modules/typescript/lib" 4 | } 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-async-dialog 2 | 3 | Yes, you can do `await dialog.alert()` out of the box! 4 | 5 | ``` 6 | npm install react-async-dialog 7 | ``` 8 | 9 | ``` 10 | yarn add react-async-dialog 11 | ``` 12 | 13 | ![storybook.png](./README/storybook.png) 14 | 15 | ### Why? 16 | 17 | [React](https://github.com/facebook/react) provides a first-class way to use [Portals](https://reactjs.org/docs/portals.html), which makes modals easy to create. 18 | 19 | But sometimes, you want a modal window that interferes your event handlers. 20 | 21 | ```jsx 22 | if ( 23 | await dialog.confirm( 24 | <> 25 | Are you REALLY sure? 26 | 27 | ) 28 | ) { 29 | console.log("Ok, you are so sure!") 30 | } 31 | ``` 32 | 33 | This library gives you this behavior out of the box! 34 | 35 | ### How to use 36 | 37 | ```jsx 38 | import { DialogProvider, useDialog } from "react-async-dialog" 39 | 40 | function YourApp({ save }) { 41 | const dialog = useDialog() 42 | 43 | const onSave = async e => { 44 | e.preventDefault() 45 | 46 | const ok = await dialog.confirm(Are you sure???, { 47 | ok: "YES!!!" 48 | }) 49 | if (!ok) { 50 | return 51 | } 52 | 53 | save() 54 | } 55 | 56 | return 57 | } 58 | 59 | ReactDOM.render( 60 | 61 | 62 | , 63 | root 64 | ) 65 | ``` 66 | 67 | ### Polyfills 68 | 69 | `react-async-dialog` requires `Promise`. 70 | -------------------------------------------------------------------------------- /README/storybook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fsubal/react-async-dialog/bcc2c3116d8a2500f9615e1d9c9155b6001d922d/README/storybook.png -------------------------------------------------------------------------------- /bili.config.ts: -------------------------------------------------------------------------------- 1 | import typescript from 'rollup-plugin-typescript2' 2 | 3 | export default { 4 | input: 'src/index.ts', 5 | plugins: { 6 | typescript2: typescript() 7 | }, 8 | output: { 9 | format: ['cjs', 'esm'] 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-async-dialog", 3 | "version": "0.1.2", 4 | "files": [ 5 | "dist" 6 | ], 7 | "main": "dist/index.js", 8 | "module": "dist/index.esm.js", 9 | "types": "dist/src/index.d.ts", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/fsubal/react-async-dialog.git" 13 | }, 14 | "author": "subal ", 15 | "license": "MIT", 16 | "scripts": { 17 | "test": "jest", 18 | "build": "bili", 19 | "prepublishOnly": "npm run build", 20 | "storybook": "start-storybook -p 6006", 21 | "build-storybook": "build-storybook" 22 | }, 23 | "jest": { 24 | "rootDir": "./src", 25 | "transform": { 26 | "^.+\\.tsx?$": "ts-jest" 27 | }, 28 | "testMatch": [ 29 | "/**/__tests__/**/*.spec.{ts,tsx}" 30 | ], 31 | "moduleFileExtensions": [ 32 | "js", 33 | "ts", 34 | "tsx" 35 | ], 36 | "testEnvironment": "jsdom", 37 | "testURL": "http://localhost/" 38 | }, 39 | "devDependencies": { 40 | "@babel/core": "^7.7.2", 41 | "@storybook/addon-actions": "^5.2.6", 42 | "@storybook/addon-knobs": "^5.2.6", 43 | "@storybook/addon-links": "^5.2.6", 44 | "@storybook/addons": "^5.2.6", 45 | "@storybook/react": "^5.2.6", 46 | "@types/jest": "^24.0.22", 47 | "@types/react": "^16.9.11", 48 | "@types/react-dom": "^16.9.4", 49 | "@types/webpack": "^4.39.8", 50 | "babel-loader": "^8.0.6", 51 | "bili": "^4.8.1", 52 | "jest": "^24.9.0", 53 | "jest-cli": "^24.9.0", 54 | "jsdom": "^15.2.1", 55 | "prettier": "^1.19.1", 56 | "react": "^16.11.0", 57 | "react-dom": "^16.11.0", 58 | "rollup-plugin-typescript2": "^0.25.2", 59 | "storybook": "^5.1.11", 60 | "ts-jest": "^24.1.0", 61 | "ts-loader": "^6.2.1", 62 | "typescript": "^3.7.2" 63 | }, 64 | "peerDependencies": { 65 | "react": ">= 16.8", 66 | "react-dom": ">= 16.8" 67 | }, 68 | "dependencies": {} 69 | } 70 | -------------------------------------------------------------------------------- /src/__stories__/layout.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { action } from "@storybook/addon-actions" 3 | import { withKnobs, text } from "@storybook/addon-knobs" 4 | import { DefaultLayout } from "../layout" 5 | 6 | export const defaultLayout = () => { 7 | return ( 8 | 13 | Hello World! 14 | 15 | ) 16 | } 17 | 18 | export const okOnly = () => { 19 | return ( 20 | 21 | Alert only shows OK (There is no cancel) 22 | 23 | ) 24 | } 25 | 26 | export default { 27 | title: "Layout", 28 | decorators: [withKnobs] 29 | } 30 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/index.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`react-async-dialog/DialogProvider just has children opens modal 1`] = `"
This message should be shown below the button
Are you REALLY sure?
"`; 4 | 5 | exports[`react-async-dialog/DialogProvider just has children renders 1`] = `"
Hello World
"`; 6 | -------------------------------------------------------------------------------- /src/__tests__/index.spec.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react" 2 | import { Simulate, act } from "react-dom/test-utils" 3 | import { render, unmountComponentAtNode } from "react-dom" 4 | import DialogProvider, { useDialog } from ".." 5 | 6 | let root: HTMLElement | undefined = undefined 7 | beforeEach(() => { 8 | root = document.createElement("div") 9 | document.body.appendChild(root) 10 | }) 11 | 12 | afterEach(() => { 13 | unmountComponentAtNode(root!) 14 | root?.remove() 15 | root = undefined 16 | }) 17 | 18 | const TestContext = React.createContext("It should not be shown!!") 19 | 20 | const TestComponent: React.FC<{ callApi: VoidFunction }> = ({ callApi }) => { 21 | const dialog = useDialog() 22 | const someContext = useContext(TestContext) 23 | 24 | const onClick = async () => { 25 | const ok = await dialog.confirm( 26 | <> 27 | Are you REALLY sure? 28 | , 29 | { ok: "YES", cancel: "NO" } 30 | ) 31 | if (!ok) { 32 | return 33 | } 34 | 35 | callApi() 36 | } 37 | 38 | return ( 39 | <> 40 | 43 |
{someContext}
44 | 45 | ) 46 | } 47 | 48 | describe("react-async-dialog/DialogProvider", () => { 49 | describe("just has children", () => { 50 | let container: HTMLElement | undefined = undefined 51 | beforeEach(() => { 52 | container = document.createElement("div") 53 | document.body.appendChild(container) 54 | }) 55 | 56 | afterEach(() => { 57 | container?.remove() 58 | container = undefined 59 | }) 60 | 61 | it("renders", () => { 62 | act(() => { 63 | render( 64 | 65 |
Hello World
66 |
, 67 | root! 68 | ) 69 | }) 70 | 71 | expect(root?.innerHTML).toMatchSnapshot() 72 | }) 73 | 74 | it("opens modal", async () => { 75 | const callApi = jest.fn() 76 | 77 | act(() => { 78 | render( 79 | 80 | 81 | 82 | 83 | , 84 | root! 85 | ) 86 | }) 87 | 88 | const button = document.querySelector("#push-me")! 89 | act(() => { 90 | Simulate.click(button) 91 | }) 92 | 93 | expect(document.body.innerHTML).toMatchSnapshot() 94 | 95 | // REVIEW: Portal's container has only OK or Cancel button 96 | // the first one should be OK button 97 | const okButton = document.querySelector("[data-dialog-root] button")! 98 | await act(async () => { 99 | Simulate.click(okButton) 100 | }) 101 | 102 | expect(callApi).toHaveBeenCalled() 103 | }) 104 | }) 105 | }) 106 | -------------------------------------------------------------------------------- /src/context.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useState, useCallback } from "react" 2 | import { createPortal } from "react-dom" 3 | import { DefaultLayout } from "./layout" 4 | import { AnyEvent, LayoutProps } from "./types" 5 | 6 | export type DialogFunction = ( 7 | children: React.ReactNode, 8 | labels?: { ok?: string; cancel?: string } 9 | ) => Promise 10 | 11 | export interface DialogValue { 12 | alert: DialogFunction 13 | confirm: DialogFunction 14 | portal: DialogFunction 15 | } 16 | 17 | export const Dialog = createContext({ 18 | alert( 19 | _children: React.ReactNode, 20 | _labels?: { ok?: string; cancel?: string } 21 | ) { 22 | throw new Error( 23 | "[react-async-dialog] Please render above in your tree, to use alert()" 24 | ) 25 | }, 26 | confirm( 27 | _children: React.ReactNode, 28 | _labels?: { ok?: string; cancel?: string } 29 | ) { 30 | throw new Error( 31 | "[react-async-dialog] Please render above in your tree, to use confirm()" 32 | ) 33 | }, 34 | portal( 35 | _children: React.ReactNode, 36 | _labels?: { ok?: string; cancel?: string } 37 | ) { 38 | throw new Error( 39 | "[react-async-dialog] Please render above in your tree, to use portal()" 40 | ) 41 | } 42 | }) 43 | 44 | const defaultLabels = { ok: "OK", cancel: "Cancel" } 45 | 46 | export default function DialogProvider({ 47 | layout = DefaultLayout, 48 | container = document.body, 49 | children 50 | }: { 51 | layout?: React.ComponentType 52 | container?: HTMLElement 53 | children: React.ReactNode 54 | }) { 55 | const [portal, setPortal] = useState(null) 56 | 57 | const createOpener = useCallback( 58 | (Component: React.ComponentType, okOnly?: boolean) => ( 59 | children: React.ReactNode, 60 | labels?: { ok?: string; cancel?: string } 61 | ) => 62 | new Promise(resolve => { 63 | const onResolve = (answer: boolean) => (e: AnyEvent) => { 64 | e.preventDefault() 65 | setPortal(null) 66 | resolve(answer) 67 | } 68 | 69 | const portal = createPortal( 70 | , 79 | container 80 | ) 81 | 82 | setPortal(portal) 83 | }), 84 | [children, container] 85 | ) 86 | 87 | return ( 88 | 95 | {children} 96 | {portal} 97 | 98 | ) 99 | } 100 | -------------------------------------------------------------------------------- /src/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react" 2 | import { Dialog } from "./context" 3 | 4 | export function useDialog() { 5 | return useContext(Dialog) 6 | } 7 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./context" 2 | export { useDialog } from "./hooks" 3 | export { DefaultLayout } from "./layout" 4 | export { LayoutProps } from "./types" 5 | -------------------------------------------------------------------------------- /src/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { LayoutProps } from "./types" 3 | 4 | /** 5 | * Using inline CSS so that no dependencies are coming 6 | * 7 | * ( want it to work no matter whether we use CSS Modules, or any CSS in JS ) 8 | */ 9 | 10 | const Backdrop: React.FC = ({ children }) => ( 11 |
25 | {children} 26 |
27 | ) 28 | 29 | const Container: React.FC = ({ children }) => ( 30 |
38 | {children} 39 |
40 | ) 41 | 42 | const Body: React.FC = ({ children }) => ( 43 |
48 | {children} 49 |
50 | ) 51 | 52 | const Footer: React.FC = ({ children }) => ( 53 |
61 | {children} 62 |
63 | ) 64 | 65 | const Button: React.FC<{ 66 | type: "primary" | "default" 67 | onClick: React.MouseEventHandler 68 | }> = ({ type, onClick, children }) => ( 69 | 88 | ) 89 | 90 | export const DefaultLayout: React.FC = ({ 91 | labels, 92 | children, 93 | onOk, 94 | onCancel 95 | }) => { 96 | return ( 97 | 98 | 99 | {children} 100 | 110 | 111 | 112 | ) 113 | } 114 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface AnyEvent { 2 | preventDefault(): void 3 | } 4 | 5 | export interface LayoutProps { 6 | labels: { ok: string; cancel?: string } 7 | children: React.ReactNode 8 | onOk(e: AnyEvent): void 9 | onCancel?: (e: AnyEvent) => void 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "declaration": true, 5 | "module": "commonjs", 6 | "jsx": "react", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true 10 | } 11 | } 12 | --------------------------------------------------------------------------------