├── .coveralls.yml ├── src ├── constants │ └── index.ts ├── components │ ├── index.ts │ ├── DefaultCloseIcon │ │ ├── index.tsx │ │ └── style.scss │ ├── DefaultButton │ │ ├── index.tsx │ │ └── style.scss │ ├── DefaultModalContent │ │ ├── style.scss │ │ └── index.tsx │ └── ModalStack │ │ ├── style.scss │ │ └── index.tsx ├── helpers │ ├── index.ts │ ├── defferCall.ts │ ├── buildStyles.ts │ ├── createElement.ts │ ├── classnames.ts │ └── defaultProps.ts ├── index.tsx ├── modules.d.ts ├── stories │ ├── Modal.stories.tsx │ └── Modal.tsx ├── style.scss ├── __tests__ │ ├── helpers.ts │ └── modal.tsx ├── renderers.tsx ├── types.ts └── component.tsx ├── tsconfig.test.json ├── .travis.yml ├── .editorconfig ├── .storybook ├── preview.js └── main.js ├── .gitignore ├── .prettierrc ├── jest.config.js ├── tsconfig.json ├── LICENSE ├── rollup.config.js ├── .eslintrc.json ├── package.json └── README.md /.coveralls.yml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/constants/index.ts: -------------------------------------------------------------------------------- 1 | export const ESC_KEY = 27 2 | export const WRAPPER_COMPONENT_ID = 'hyper-modal-wrapper_component_id' 3 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | } 6 | } -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './DefaultButton' 2 | export * from './DefaultCloseIcon' 3 | export * from './DefaultModalContent' 4 | export * from './ModalStack' 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - "14" 5 | after_script: 6 | - "npm run test" 7 | - "npm install coveralls && cat ./coverage/lcov.info | coveralls" 8 | -------------------------------------------------------------------------------- /src/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './buildStyles' 2 | export * from './classnames' 3 | export * from './createElement' 4 | export * from './defaultProps' 5 | export * from './defferCall' 6 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { HyperModal } from './component' 2 | import { ModalStack } from './components' 3 | 4 | export * from './types' 5 | 6 | export default HyperModal 7 | 8 | export { ModalStack } 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /src/modules.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.scss' { 2 | const content: { [className: string]: string } 3 | export default content 4 | } 5 | 6 | declare module 'body-scroll-lock/lib/bodyScrollLock.es6' 7 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | export const parameters = { 2 | actions: { argTypesRegex: "^on[A-Z].*" }, 3 | controls: { 4 | matchers: { 5 | color: /(background|color)$/i, 6 | date: /Date$/, 7 | }, 8 | }, 9 | } -------------------------------------------------------------------------------- /src/components/DefaultCloseIcon/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styles from './style.scss' 3 | 4 | export const DefaultCloseIcon = () => 5 | 6 | export default DefaultCloseIcon 7 | -------------------------------------------------------------------------------- /src/helpers/defferCall.ts: -------------------------------------------------------------------------------- 1 | export const defferCall = (func: any, timeout = 500, args: any = {}) => 2 | new Promise(resolve => { 3 | setTimeout(() => { 4 | func(args) 5 | resolve(null) 6 | }, timeout) 7 | }) 8 | -------------------------------------------------------------------------------- /src/components/DefaultCloseIcon/style.scss: -------------------------------------------------------------------------------- 1 | .hyperCloseIcon { 2 | display: block; 3 | background-image: url('https://api.iconify.design/iwwa:close.svg'); 4 | background-repeat: no-repeat; 5 | background-size: contain; 6 | background-position: center; 7 | width: 100%; 8 | height: 100%; 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/ignore-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | node_modules 6 | 7 | # builds 8 | build 9 | dist 10 | .rpt2_cache 11 | .size-snapshot.json 12 | # misc 13 | .DS_Store 14 | .env 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | 24 | coverage 25 | -------------------------------------------------------------------------------- /src/components/DefaultButton/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styles from './style.scss' 3 | 4 | export interface DefaultButtonProps { 5 | onClick?: () => void 6 | } 7 | 8 | export const DefaultButton: React.FC = ({ onClick }) => ( 9 | 12 | ) 13 | 14 | export default DefaultButton 15 | -------------------------------------------------------------------------------- /src/helpers/buildStyles.ts: -------------------------------------------------------------------------------- 1 | import { IPositionProps } from '../types' 2 | import { defaultProps } from './defaultProps' 3 | 4 | export const buildContentStyle = (position?: IPositionProps) => { 5 | const defaultStyles = { 6 | display: 'flex', 7 | ...defaultProps.position 8 | } 9 | if (position) { 10 | return { 11 | ...defaultStyles, 12 | ...position 13 | } 14 | } 15 | return defaultStyles 16 | } 17 | -------------------------------------------------------------------------------- /src/stories/Modal.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { ComponentStory, ComponentMeta } from '@storybook/react' 3 | 4 | import { Modal } from './Modal' 5 | 6 | export default { 7 | title: 'Modal', 8 | component: Modal 9 | } as ComponentMeta 10 | 11 | const Template: ComponentStory = () => 12 | 13 | export const Primary = Template.bind({}) 14 | 15 | Primary.args = { 16 | primary: true, 17 | label: 'Modal' 18 | } 19 | -------------------------------------------------------------------------------- /src/components/DefaultButton/style.scss: -------------------------------------------------------------------------------- 1 | .customButton { 2 | height: 60px; 3 | min-width: 200px; 4 | font-size: 1.2em; 5 | padding: 0 20px; 6 | border: 0; 7 | cursor: pointer; 8 | text-transform: uppercase; 9 | background-color: rgba(#41465a, 0.5); 10 | box-shadow: 0px 0px 5px 1px rgba(#313443, 0.4); 11 | transition: all 0.2s ease; 12 | 13 | &:hover { 14 | background-color: #41465a; 15 | color: #e7e8ee; 16 | box-shadow: 0px 0px 5px 1px rgba(#313443, 0.6); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/helpers/createElement.ts: -------------------------------------------------------------------------------- 1 | export const createElement = (className?: string): HTMLElement => { 2 | let currentNode = document.getElementById('hyper-modal-portal-id') 3 | if (!currentNode) { 4 | currentNode = document.createElement('div') 5 | currentNode.setAttribute('id', 'hyper-modal-portal-id') 6 | document.body.appendChild(currentNode) 7 | } 8 | if (className && !currentNode.classList.contains(className)) { 9 | currentNode.classList.add(className) 10 | } 11 | return currentNode 12 | } 13 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "jsxSingleQuote": true, 4 | "semi": false, 5 | "useTabs": false, 6 | "tabWidth": 4, 7 | "printWidth": 120, 8 | "trailingComma": "none", 9 | "overrides": [ 10 | { 11 | "files": "*.tsx", 12 | "options": { 13 | "tabWidth": 4, 14 | "semi": false, 15 | "singleQuote": true, 16 | "jsxSingleQuote": false 17 | } 18 | }, 19 | { 20 | "files": "*.ts", 21 | "options": { 22 | "tabWidth": 4, 23 | "semi": false, 24 | "singleQuote": true 25 | } 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /src/helpers/classnames.ts: -------------------------------------------------------------------------------- 1 | export type ClassNamesObjectProps = { [key: string]: boolean } 2 | 3 | export type ClassNamesProps = string | undefined 4 | 5 | export const convertObjectToString = (classes: { [key: string]: boolean }): string => { 6 | if (!classes) { 7 | return '' 8 | } 9 | return Object.keys(classes) 10 | .filter((key: string) => !!classes[key]) 11 | .reduce((classString, item) => (classString ? `${classString}${item ? ` ${item}` : ''}` : `${item}`), '') 12 | } 13 | 14 | export const classnames = (...classes: (ClassNamesObjectProps | ClassNamesProps)[]) => { 15 | if (classes[0] && typeof classes[0] === 'string') { 16 | return classes.join(' ') 17 | } 18 | return convertObjectToString(classes[0] as ClassNamesObjectProps) 19 | } 20 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | collectCoverageFrom: [ 5 | '/src/**/*.ts', 6 | '/src/**/*.tsx', 7 | '!/src/**/*.d.ts', 8 | '!/src/**/*.spec.ts', 9 | '!/src/**/*.test.ts', 10 | '!/src/**/__*__/*', 11 | '!/src/types.ts', 12 | ], 13 | testMatch: [ 14 | '**/src/__tests__/**/*.(ts|tsx)', 15 | ], 16 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], 17 | moduleNameMapper: { 18 | '\\.(css|scss)$': 'identity-obj-proxy', 19 | }, 20 | transform: { 21 | '^.+\\.tsx?$': 'ts-jest', 22 | }, 23 | coveragePathIgnorePatterns: [ 24 | 'node_modules', 25 | 'dist', 26 | 'src/types.ts', 27 | 'src/stories', 28 | ], 29 | } 30 | -------------------------------------------------------------------------------- /src/components/DefaultModalContent/style.scss: -------------------------------------------------------------------------------- 1 | .hyperModalDefaultContent { 2 | width: 100%; 3 | height: 100%; 4 | display: flex; 5 | flex-direction: column; 6 | justify-content: space-around; 7 | align-items: center; 8 | color: #282c34; 9 | overflow: auto; 10 | 11 | .title { 12 | font-size: 30px; 13 | color: #282c34; 14 | } 15 | 16 | .description { 17 | font-size: 24px; 18 | } 19 | 20 | button { 21 | width: 200px; 22 | height: 50px; 23 | font-size: 18px; 24 | font-weight: bold; 25 | background-color: white; 26 | color: #282c34; 27 | border: 1px solid #282c34; 28 | text-transform: uppercase; 29 | transition: all 0.2s ease; 30 | cursor: pointer; 31 | 32 | &:hover { 33 | background-color: #282c34; 34 | color: white; 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "outDir": "build", 5 | "module": "esnext", 6 | "target": "es5", 7 | "lib": [ 8 | "es6", 9 | "dom", 10 | "es2015", 11 | "es2016", 12 | "es2017" 13 | ], 14 | "allowJs": false, 15 | "jsx": "react", 16 | "declaration": true, 17 | "moduleResolution": "node", 18 | "forceConsistentCasingInFileNames": true, 19 | "noImplicitReturns": true, 20 | "noImplicitThis": false, 21 | "noImplicitAny": true, 22 | "strictNullChecks": true, 23 | "suppressImplicitAnyIndexErrors": true, 24 | "noUnusedLocals": true, 25 | "noUnusedParameters": true, 26 | "types": [ 27 | "jest" 28 | ], 29 | }, 30 | "include": [ 31 | "./src/**/*.ts", 32 | "./src/**/*.tsx" 33 | ], 34 | "exclude": [ 35 | "build", 36 | "rollup.config.js", 37 | "node_modules" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /src/components/ModalStack/style.scss: -------------------------------------------------------------------------------- 1 | .stackWrapper { 2 | position: relative; 3 | width: 60%; 4 | height: 60%; 5 | display: flex; 6 | justify-content: center; 7 | } 8 | 9 | .hyperModalContentWrapper { 10 | position: absolute; 11 | box-shadow: 0 10px 20px rgba(0, 0, 0, 0.19), 0 6px 6px rgba(0, 0, 0, 0.23); 12 | background-color: #ffffff; 13 | width: 100%; 14 | height: 100%; 15 | z-index: 5; 16 | border-radius: 10px; 17 | overflow: auto; 18 | 19 | &.fullscreen { 20 | width: 100%; 21 | height: 100%; 22 | } 23 | 24 | .hyperCloseIconWrapper { 25 | position: absolute; 26 | display: block; 27 | cursor: pointer; 28 | width: 40px; 29 | height: 40px; 30 | top: 20px; 31 | right: 20px; 32 | transition: all 0.1s ease; 33 | 34 | &:hover { 35 | transform: rotate(90deg); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/helpers/defaultProps.ts: -------------------------------------------------------------------------------- 1 | export const defaultProps = { 2 | ariaEnabled: true, 3 | ariaProps: { 4 | 'aria-describedby': 'hyper-modal-description', 5 | 'aria-labelledby': 'hyper-modal-title', 6 | role: 'dialog' 7 | }, 8 | disableScroll: true, 9 | childrenMode: true, 10 | closeDebounceTimeout: 0, 11 | closeIconPosition: { 12 | vertical: 'top' as const, 13 | horizontal: 'right' as const 14 | }, 15 | closeOnCloseIconClick: true, 16 | closeOnDimmerClick: true, 17 | closeOnEscClick: true, 18 | dimmerEnabled: true, 19 | isFullscreen: false, 20 | portalMode: false, 21 | position: { 22 | alignItems: 'center' as const, 23 | justifyContent: 'center' as const 24 | }, 25 | stackable: false, 26 | stackableIndex: 0, 27 | stackContentSettings: { 28 | widthRatio: 4, 29 | topOffsetRatio: 2, 30 | transition: 'all 0.3s ease', 31 | opacityRatio: 0.2 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Aleksey Makhankov 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 | -------------------------------------------------------------------------------- /src/components/DefaultModalContent/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { IARIAProps } from '../../types' 3 | 4 | import styles from './style.scss' 5 | 6 | export interface DefaultModalContentProps { 7 | ariaEnabled?: boolean 8 | ariaProps?: IARIAProps 9 | handleClose: () => void 10 | } 11 | 12 | export const DefaultModalContent: React.FC = ({ ariaEnabled, ariaProps, handleClose }) => { 13 | const labelId = ariaEnabled && ariaProps ? ariaProps['aria-labelledby'] : undefined 14 | const descriptionId = ariaEnabled && ariaProps ? ariaProps['aria-describedby'] : undefined 15 | 16 | return ( 17 |
18 |
19 | Hyper modal 20 |
21 |
22 | Fully customizable and accessible modal 23 |
24 | 27 |
28 | ) 29 | } 30 | 31 | export default DefaultModalContent 32 | -------------------------------------------------------------------------------- /src/style.scss: -------------------------------------------------------------------------------- 1 | .hyperModalWrapper { 2 | position: fixed; 3 | top: 0; 4 | bottom: 0; 5 | left: 0; 6 | right: 0; 7 | visibility: hidden; 8 | opacity: 0; 9 | transition: all 0.3s ease; 10 | 11 | &.visible { 12 | visibility: visible; 13 | opacity: 1; 14 | } 15 | 16 | .hyperModalContentWrapper { 17 | position: relative; 18 | background-color: #ffffff; 19 | width: 60%; 20 | height: 60%; 21 | z-index: 5; 22 | border-radius: 10px; 23 | overflow: auto; 24 | 25 | &.fullscreen { 26 | width: 100%; 27 | height: 100%; 28 | } 29 | } 30 | } 31 | 32 | .hyperDimmerWrapper { 33 | background-color: rgba(0, 0, 0, 0.5); 34 | position: absolute; 35 | top: 0; 36 | bottom: 0; 37 | left: 0; 38 | right: 0; 39 | z-index: 1; 40 | } 41 | 42 | .hyperCloseIconWrapper { 43 | position: absolute; 44 | display: block; 45 | cursor: pointer; 46 | width: 40px; 47 | height: 40px; 48 | top: 20px; 49 | right: 20px; 50 | transition: all 0.1s ease; 51 | 52 | &:hover { 53 | transform: rotate(90deg); 54 | } 55 | } 56 | 57 | .body-noscroll { 58 | overflow: hidden; 59 | } 60 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], 5 | addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions'], 6 | framework: '@storybook/react', 7 | core: { 8 | builder: 'webpack5' 9 | }, 10 | webpackFinal: async (config, { configType }) => { 11 | // Make whatever fine-grained changes you need 12 | config.module.rules.push({ 13 | test: /\.scss$/, 14 | use: [ 15 | { 16 | loader: 'style-loader' 17 | }, 18 | { 19 | loader: 'css-loader', 20 | options: { 21 | modules: { 22 | localIdentName: '[name]__[local]___[hash:base64:5]', 23 | exportLocalsConvention: 'camelCaseOnly' 24 | }, 25 | importLoaders: 1 26 | } 27 | }, 28 | { 29 | loader: 'sass-loader' 30 | } 31 | ], 32 | include: path.resolve(__dirname, '../') 33 | }) 34 | 35 | // Return the altered config 36 | return config 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/components/ModalStack/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { classnames } from '../../helpers' 3 | import { ModalStackProps } from '../../types' 4 | import styles from './style.scss' 5 | 6 | export const ModalStack: React.FC = ({ 7 | children, 8 | classes, 9 | closeIcon, 10 | getProps, 11 | handleClose, 12 | isFullscreen, 13 | modalContentRef, 14 | stackableIndex = 0, 15 | ...props 16 | }) => ( 17 |
18 | {React.Children.toArray(children) 19 | .slice(0, stackableIndex + 1) 20 | .map((child: React.ReactElement, index, array) => ( 21 |
32 | {child} 33 | {closeIcon} 34 |
35 | ))} 36 |
37 | ) 38 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescriptPlugin from 'rollup-plugin-typescript2' 2 | import commonjs from '@rollup/plugin-commonjs' 3 | import external from 'rollup-plugin-peer-deps-external' 4 | import postcss from 'rollup-plugin-postcss' 5 | import resolve from '@rollup/plugin-node-resolve' 6 | import url from '@rollup/plugin-url' 7 | import svgr from '@svgr/rollup' 8 | import progress from 'rollup-plugin-progress' 9 | import { sizeSnapshot } from 'rollup-plugin-size-snapshot' 10 | import { terser } from 'rollup-plugin-terser' 11 | 12 | import pkg from './package.json' 13 | 14 | export default { 15 | input: './src/index.tsx', 16 | output: [ 17 | { 18 | file: pkg.main, 19 | format: 'cjs', 20 | exports: 'auto', 21 | sourcemap: true 22 | }, 23 | { 24 | file: pkg.module, 25 | format: 'es', 26 | exports: 'auto', 27 | sourcemap: true 28 | } 29 | ], 30 | plugins: [ 31 | progress(), 32 | external(), 33 | postcss({ 34 | inject: true, 35 | modules: { 36 | generateScopedName: 'hm_[local]' 37 | }, 38 | autoModules: false 39 | }), 40 | url(), 41 | svgr(), 42 | resolve(), 43 | typescriptPlugin({ 44 | rollupCommonJSResolveHack: true, 45 | clean: true 46 | }), 47 | commonjs(), 48 | sizeSnapshot(), 49 | terser() 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /src/stories/Modal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import HyperModal, { ModalStack, ModalStackProps } from '../index' 3 | 4 | export const Modal = () => { 5 | const [index, setIndex] = React.useState(0) 6 | return ( 7 | ( 12 | 15 | )} 16 | stackContentSettings={{ 17 | topOffsetRatio: 10 18 | }} 19 | > 20 | {(props: ModalStackProps) => ( 21 | 22 |
23 |
1
24 | 25 | 26 |
27 |
28 |
2
29 | 30 | 31 | 32 |
33 |
34 |
3
35 | 36 | 37 |
38 |
39 | )} 40 |
41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /src/__tests__/helpers.ts: -------------------------------------------------------------------------------- 1 | import { buildContentStyle, createElement, defferCall, defaultProps, classnames } from '../helpers' 2 | import { IPositionProps } from '../types' 3 | 4 | it('should generate correct styles', () => { 5 | const { position: defaultPosition } = defaultProps 6 | const position: IPositionProps = { 7 | justifyContent: 'flex-start', 8 | alignItems: 'flex-end' 9 | } 10 | 11 | expect(buildContentStyle).toBeInstanceOf(Function) 12 | 13 | expect(buildContentStyle()).toMatchObject(defaultPosition) 14 | 15 | expect(buildContentStyle({})).toMatchObject(defaultPosition) 16 | 17 | const styles = buildContentStyle(position) 18 | expect(styles).toMatchObject({ 19 | display: 'flex', 20 | justifyContent: 'flex-start', 21 | alignItems: 'flex-end' 22 | }) 23 | const testClassNames = { 24 | first: 'first', 25 | second: 'second' 26 | } 27 | const resultClassName = 'first second' 28 | expect(classnames(testClassNames.first, testClassNames.second)).toBe(resultClassName) 29 | }) 30 | 31 | it('should create correct node', () => { 32 | const node = createElement() 33 | expect(node).toBeInstanceOf(HTMLDivElement) 34 | expect(node.id).toEqual('hyper-modal-portal-id') 35 | let isHasNode = document.body.contains(node) 36 | expect(isHasNode).toBeTruthy() 37 | const nodeWithClassName = createElement('class-name') 38 | expect(nodeWithClassName).toBeInstanceOf(HTMLDivElement) 39 | expect(nodeWithClassName.id).toEqual('hyper-modal-portal-id') 40 | expect(nodeWithClassName.classList.contains('class-name')).toBeTruthy() 41 | isHasNode = document.body.contains(nodeWithClassName) 42 | expect(isHasNode).toBeTruthy() 43 | }) 44 | 45 | it('should correct call deffer function', async () => { 46 | const func = jest.fn() 47 | await defferCall(func) 48 | expect(func).toBeCalled() 49 | }) 50 | -------------------------------------------------------------------------------- /src/renderers.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { DefaultButton, DefaultCloseIcon } from './components' 3 | import { classnames } from './helpers' 4 | import { IClassNamesProps, ICloseIconPosition } from './types' 5 | import styles from './style.scss' 6 | 7 | export type RenderButtonProps = { 8 | renderOpenButton?: boolean | ((requestOpen: () => void) => JSX.Element | string) 9 | open: () => void 10 | } 11 | 12 | export type RenderCloseIcon = { 13 | classes?: IClassNamesProps 14 | renderCloseIconProp?: () => JSX.Element | null | string 15 | closeOnCloseIconClick?: boolean 16 | closeIconPosition?: ICloseIconPosition 17 | close: () => void 18 | } 19 | 20 | export type RenderDimmer = { 21 | classes?: IClassNamesProps 22 | closeOnDimmerClick?: boolean 23 | close: () => void 24 | } 25 | 26 | export const renderButton = ({ renderOpenButton, open }: RenderButtonProps) => { 27 | if (renderOpenButton) { 28 | if (typeof renderOpenButton === 'boolean') { 29 | return 30 | } 31 | return renderOpenButton(open) 32 | } 33 | return null 34 | } 35 | 36 | export const renderCloseIcon = ({ 37 | classes, 38 | renderCloseIconProp, 39 | closeOnCloseIconClick, 40 | closeIconPosition, 41 | close 42 | }: RenderCloseIcon) => { 43 | const iconClassNames = closeIconPosition 44 | ? classnames(styles.hyperCloseIconWrapper, closeIconPosition.horizontal, closeIconPosition.vertical) 45 | : styles.hyperCloseIconWrapper 46 | return ( 47 |
52 | {renderCloseIconProp ? renderCloseIconProp() : } 53 |
54 | ) 55 | } 56 | 57 | export const renderDimmer = ({ classes, closeOnDimmerClick, close }: RenderDimmer) => ( 58 |
62 | ) 63 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export type TModalPosition = 'flex-start' | 'center' | 'flex-end' 4 | export type THorizontalPosition = 'left' | 'center' | 'right' 5 | export type TVerticalPosition = 'top' | 'middle' | 'bottom' 6 | export type StackContentSettings = { 7 | widthRatio?: number 8 | topOffsetRatio?: number 9 | opacityRatio?: number 10 | transition?: string 11 | } 12 | 13 | export interface IClassNamesProps { 14 | closeIconClassName?: string 15 | contentClassName?: string 16 | dimmerClassName?: string 17 | portalWrapperClassName?: string 18 | wrapperClassName?: string 19 | } 20 | 21 | export interface IARIAProps { 22 | 'aria-describedby'?: string 23 | 'aria-labelledby'?: string 24 | role?: string 25 | } 26 | 27 | export interface IPositionProps { 28 | alignItems?: TModalPosition 29 | justifyContent?: TModalPosition 30 | } 31 | 32 | export interface ICloseIconPosition { 33 | horizontal?: THorizontalPosition 34 | vertical?: TVerticalPosition 35 | } 36 | 37 | export interface IModalProps { 38 | afterClose?: () => void 39 | ariaEnabled?: boolean 40 | ariaProps?: IARIAProps 41 | beforeClose?: () => void 42 | children?: React.ReactNode | ((props: ModalStackProps) => any) 43 | childrenMode?: boolean 44 | classes?: IClassNamesProps 45 | closeDebounceTimeout?: number 46 | closeIconPosition?: ICloseIconPosition 47 | closeOnCloseIconClick?: boolean 48 | closeOnDimmerClick?: boolean 49 | closeOnEscClick?: boolean 50 | dimmerEnabled?: boolean 51 | disableScroll?: boolean 52 | isFullscreen?: boolean 53 | isOpen?: boolean 54 | modalContentRef?: React.RefObject 55 | modalWrapperRef?: React.RefObject 56 | portalMode?: boolean 57 | portalNode?: HTMLElement 58 | position?: IPositionProps 59 | renderCloseIcon?: () => JSX.Element | null | string 60 | renderContent?: () => JSX.Element | JSX.Element[] | null | string 61 | renderOpenButton?: boolean | ((requestOpen: () => void) => JSX.Element | string) 62 | requestClose?: () => void 63 | stackable?: boolean 64 | stackableIndex?: number 65 | stackContentSettings?: StackContentSettings 66 | unmountOnClose?: boolean 67 | } 68 | 69 | export interface ModalStackProps { 70 | children: React.ReactElement[] 71 | classes?: any 72 | closeIcon?: any 73 | getProps: (index: number, childProps: any, childrenLength: number) => any 74 | handleClose: () => void 75 | isFullscreen?: boolean 76 | modalContentRef?: any 77 | stackableIndex: number 78 | } 79 | 80 | export interface IModalState { 81 | isInnerOpen: boolean 82 | } 83 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "jsx": true, 5 | "useJSXTextNode": true 6 | }, 7 | "env": { 8 | "browser": true, 9 | "jest": true 10 | }, 11 | "plugins": ["@typescript-eslint", "react-hooks", "jest"], 12 | "extends": ["airbnb", "plugin:@typescript-eslint/recommended", "plugin:import/typescript"], 13 | "rules": { 14 | "semi": [2, "never"], 15 | "max-len": ["warn", { "code": 120 }], 16 | "indent": ["error", 4], 17 | "comma-dangle": "off", 18 | "implicit-arrow-linebreak": "off", 19 | "arrow-parens": "off", 20 | "no-underscore-dangle": "off", 21 | "operator-linebreak": ["none"], 22 | "import/named": "off", 23 | "import/export": "off", 24 | "import/extensions": "off", 25 | "import/prefer-default-export": "off", 26 | "import/no-extraneous-dependencies": "off", 27 | "jsx-a11y/click-events-have-key-events": "off", 28 | "jsx-a11y/no-static-element-interactions": "off", 29 | "react/jsx-props-no-spreading": "off", 30 | "lines-between-class-members": "off", 31 | "no-param-reassign": "off", 32 | "object-curly-newline": "off", 33 | "max-classes-per-file": "off", 34 | "class-methods-use-this": "off", 35 | "no-unused-expressions": [ 36 | "warn", 37 | { 38 | "allowShortCircuit": true, 39 | "allowTernary": true 40 | } 41 | ], 42 | "@typescript-eslint/prefer-interface": "off", 43 | "@typescript-eslint/explicit-function-return-type": "off", 44 | "@typescript-eslint/no-explicit-any": "off", 45 | "@typescript-eslint/interface-name-prefix": "off", 46 | "@typescript-eslint/no-explicit-this": "off", 47 | "@typescript-eslint/no-this-alias": "off", 48 | "@typescript-eslint/no-non-null-assertion": "off", 49 | "@typescript-eslint/no-unused-vars": ["warn", { "varsIgnorePattern": "_" }], 50 | "react/jsx-filename-extension": [ 51 | "warn", 52 | { 53 | "extensions": [".jsx", ".tsx"] 54 | } 55 | ], 56 | "jsx-quotes": ["warn", "prefer-double"], 57 | "react/jsx-indent": ["warn", 4], 58 | "react/jsx-indent-props": ["warn", 4], 59 | "react/prop-types": "off", 60 | "react-hooks/rules-of-hooks": "error", 61 | "react-hooks/exhaustive-deps": "warn", 62 | "react/require-default-props": "off", 63 | "react/no-array-index-key": "off", 64 | "react/function-component-definition": [ 65 | "warn", 66 | { 67 | "namedComponents": "arrow-function", 68 | "unnamedComponents": "arrow-function" 69 | } 70 | ] 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-hyper-modal", 3 | "version": "1.2.16", 4 | "description": "Fully customizable and accessible modal react component", 5 | "author": "Aleksey Makhankov", 6 | "license": "MIT", 7 | "repository": "https://github.com/alekseymakhankov/hyper-modal.git", 8 | "main": "dist/index.js", 9 | "module": "dist/index.es.js", 10 | "jsnext:main": "dist/index.es.js", 11 | "types": "./dist/index.d.ts", 12 | "engines": { 13 | "node": ">=14", 14 | "npm": ">=5" 15 | }, 16 | "scripts": { 17 | "test": "cross-env CI=1 jest --env=jsdom --coverage --coverageReporters=text-lcov | coveralls", 18 | "test:local": "jest --env=jsdom --coverage", 19 | "test:watch": "react-scripts-ts test --env=jsdom", 20 | "build": "rollup -c", 21 | "start": "rollup -c -w", 22 | "prepare": "npm run build", 23 | "storybook": "start-storybook -p 6006", 24 | "build-storybook": "build-storybook" 25 | }, 26 | "dependencies": { 27 | "react-remove-scroll": "^2.4.4" 28 | }, 29 | "peerDependencies": { 30 | "react": "^15.0.0 || ^16 || ^17 || ^18", 31 | "react-dom": "^15.0.0 || ^16 || ^17 || ^18" 32 | }, 33 | "devDependencies": { 34 | "@babel/core": "^7.17.9", 35 | "@babel/preset-env": "^7.16.11", 36 | "@babel/preset-react": "^7.16.7", 37 | "@rollup/plugin-babel": "^5.3.1", 38 | "@rollup/plugin-commonjs": "^21.0.3", 39 | "@rollup/plugin-node-resolve": "^13.2.0", 40 | "@rollup/plugin-url": "^6.1.0", 41 | "@storybook/addon-actions": "^6.4.21", 42 | "@storybook/addon-essentials": "^6.4.21", 43 | "@storybook/addon-interactions": "^6.4.21", 44 | "@storybook/addon-links": "^6.4.21", 45 | "@storybook/builder-webpack5": "^6.4.21", 46 | "@storybook/manager-webpack5": "^6.4.21", 47 | "@storybook/react": "^6.4.21", 48 | "@storybook/testing-library": "^0.0.9", 49 | "@svgr/rollup": "^6.2.1", 50 | "@testing-library/react": "^13.0.1", 51 | "@types/enzyme": "^3.10.12", 52 | "@types/enzyme-adapter-react-16": "^1.0.6", 53 | "@types/jest": "^27.4.1", 54 | "@types/react": "^17.0.2", 55 | "@types/react-dom": "^17.0.2", 56 | "@types/react-lifecycles-compat": "^3.0.1", 57 | "@types/react-test-renderer": "^17.0.1", 58 | "@typescript-eslint/eslint-plugin": "^ 5.19.0", 59 | "@typescript-eslint/parser": "^5.19.0", 60 | "@wojtekmaj/enzyme-adapter-react-17": "^0.6.7", 61 | "babel-loader": "^8.2.4", 62 | "coveralls": "^3.1.1", 63 | "cross-env": "^7.0.3", 64 | "css-loader": "^6.7.1", 65 | "enzyme": "^3.11.0", 66 | "enzyme-adapter-react-16": "^1.15.6", 67 | "eslint": "^8.13.0", 68 | "eslint-config-airbnb": "^19.0.4", 69 | "eslint-plugin-import": "^2.26.0", 70 | "eslint-plugin-jest": "^26.1.4", 71 | "eslint-plugin-jsx-a11y": "^6.5.1", 72 | "eslint-plugin-react": "^7.29.4", 73 | "eslint-plugin-react-hooks": "^4.4.0", 74 | "glob-parent": ">=5.1.2", 75 | "html-webpack-plugin": "^5.5.0", 76 | "identity-obj-proxy": "^3.0.0", 77 | "jest": "^27.5.1", 78 | "node-sass": "^7.0.1", 79 | "postcss": "^8.4.12", 80 | "react": "^16.13.1", 81 | "react-docgen-typescript-loader": "^3.6.0", 82 | "react-dom": "^16.13.1", 83 | "react-test-renderer": "^18.0.0", 84 | "rollup": "^2.70.1", 85 | "rollup-plugin-peer-deps-external": "^2.2.4", 86 | "rollup-plugin-postcss": "^4.0.2", 87 | "rollup-plugin-progress": "^1.1.2", 88 | "rollup-plugin-size-snapshot": "^0.12.0", 89 | "rollup-plugin-terser": "^7.0.2", 90 | "rollup-plugin-typescript2": "^0.31.2", 91 | "sass-loader": "^12.6.0", 92 | "style-loader": "^3.3.1", 93 | "trim": ">=0.0.3", 94 | "ts-jest": "^27.1.4", 95 | "typescript": "^4.6.3", 96 | "webpack": "^5.72.0" 97 | }, 98 | "files": [ 99 | "dist" 100 | ], 101 | "keywords": [ 102 | "react", 103 | "modal", 104 | "react-modal", 105 | "accessible", 106 | "dialog", 107 | "react-dialog" 108 | ] 109 | } 110 | -------------------------------------------------------------------------------- /src/__tests__/modal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { act } from 'react-dom/test-utils' 3 | import * as Enzyme from 'enzyme' 4 | import * as Adapter from '@wojtekmaj/enzyme-adapter-react-17' 5 | import Modal, { ModalStack } from '../index' 6 | 7 | const { mount } = Enzyme 8 | 9 | Enzyme.configure({ adapter: new Adapter() }) 10 | 11 | it('render without crashing', () => { 12 | const wrapper = mount( ({})} />) 13 | expect(wrapper.instance()).toBeDefined() 14 | }) 15 | 16 | it('should be unmounted', () => { 17 | const wrapper = mount( 18 | ( 21 | 24 | )} 25 | unmountOnClose 26 | /> 27 | ) 28 | const button = document.getElementById('open-button') 29 | if (button) { 30 | act(() => { 31 | button.click() 32 | }) 33 | } 34 | wrapper.simulate('keyDown', { key: 'Escape' }) 35 | expect(wrapper.instance()).toBeDefined() 36 | wrapper.unmount() 37 | expect(wrapper).toMatchObject({}) 38 | }) 39 | 40 | it('should afterClose should be executed', () => { 41 | const afterClose = jest.fn() 42 | const wrapper = mount( 43 | ({})} 47 | closeIconPosition={{ vertical: 'bottom', horizontal: 'center' }} 48 | afterClose={afterClose} 49 | /> 50 | ) 51 | const icon = wrapper.find('div[data-name="close-icon"]') 52 | expect(icon).toBeDefined() 53 | const handler = icon.first().prop('onClick') 54 | if (handler) { 55 | act(() => { 56 | handler({} as any) 57 | }) 58 | } 59 | expect(afterClose).toBeCalled() 60 | }) 61 | 62 | it('should beforeClose should be executed', () => { 63 | const beforeClose = jest.fn() 64 | const wrapper = mount( ({})} beforeClose={beforeClose} />) 65 | const icon = wrapper.find('div[data-name="close-icon"]') 66 | expect(icon).toBeDefined() 67 | const handler = icon.first().prop('onClick') 68 | if (handler) { 69 | act(() => { 70 | handler({} as any) 71 | }) 72 | } 73 | expect(beforeClose).toBeCalled() 74 | }) 75 | 76 | it('should be closed by timeout', async () => { 77 | const beforeClose = jest.fn() 78 | const afterClose = jest.fn() 79 | const wrapper = mount( 80 | ({})} 83 | afterClose={afterClose} 84 | beforeClose={beforeClose} 85 | closeDebounceTimeout={1000} 86 | portalMode 87 | /> 88 | ) 89 | const icon = wrapper.find('div[data-name="close-icon"]') 90 | expect(icon).toBeDefined() 91 | const handler = icon.first().prop('onClick') 92 | if (handler) { 93 | await act(async () => { 94 | await handler({} as any) 95 | }) 96 | expect(beforeClose).toBeCalled() 97 | expect(afterClose).toBeCalled() 98 | } 99 | }) 100 | 101 | it('render stack without crashing', () => { 102 | const wrapper = mount( 103 | ( 109 | 112 | )} 113 | > 114 | {props => ( 115 | 116 |
117 |
1
118 |
119 |
120 |
2
121 | 122 |
123 |
124 |
3
125 |
126 |
127 | )} 128 |
129 | ) 130 | 131 | expect(wrapper.instance()).toBeDefined() 132 | }) 133 | -------------------------------------------------------------------------------- /src/component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { createPortal } from 'react-dom' 3 | import { RemoveScroll } from 'react-remove-scroll' 4 | import { DefaultModalContent } from './components' 5 | import { buildContentStyle, classnames, createElement, defaultProps, defferCall } from './helpers' 6 | import { renderButton, renderCloseIcon, renderDimmer } from './renderers' 7 | import { ESC_KEY, WRAPPER_COMPONENT_ID } from './constants' 8 | import { IModalProps } from './types' 9 | import styles from './style.scss' 10 | 11 | export const HyperModal: React.FC = ({ 12 | afterClose, 13 | ariaEnabled = defaultProps.ariaEnabled, 14 | ariaProps = defaultProps.ariaProps, 15 | beforeClose, 16 | children, 17 | childrenMode = defaultProps.childrenMode, 18 | classes, 19 | closeDebounceTimeout = defaultProps.closeDebounceTimeout, 20 | closeIconPosition = defaultProps.closeIconPosition, 21 | closeOnCloseIconClick = defaultProps.closeOnCloseIconClick, 22 | closeOnDimmerClick = defaultProps.closeOnDimmerClick, 23 | closeOnEscClick = defaultProps.closeOnEscClick, 24 | dimmerEnabled = defaultProps.dimmerEnabled, 25 | disableScroll = defaultProps.disableScroll, 26 | isFullscreen = defaultProps.isFullscreen, 27 | isOpen, 28 | modalContentRef, 29 | modalWrapperRef, 30 | portalMode = defaultProps.portalMode, 31 | portalNode, 32 | position = defaultProps.position, 33 | renderCloseIcon: renderCloseIconProp, 34 | renderContent, 35 | renderOpenButton, 36 | requestClose, 37 | stackable = defaultProps.stackable, 38 | stackableIndex = defaultProps.stackableIndex, 39 | stackContentSettings = defaultProps.stackContentSettings, 40 | unmountOnClose 41 | }) => { 42 | const [isInnerOpen, setInnerOpen] = React.useState(isOpen || false) 43 | 44 | React.useEffect(() => { 45 | if (typeof isOpen !== 'undefined') { 46 | setInnerOpen(isOpen) 47 | } 48 | }, [isOpen]) 49 | 50 | const openModal = React.useCallback(() => { 51 | setInnerOpen(true) 52 | }, []) 53 | 54 | const closeModal = React.useCallback(() => { 55 | setInnerOpen(false) 56 | }, []) 57 | 58 | const handleAfterClose = React.useCallback(() => { 59 | if (afterClose) { 60 | afterClose() 61 | } 62 | }, [afterClose]) 63 | 64 | const handleClose = React.useCallback(() => { 65 | if (beforeClose) { 66 | beforeClose() 67 | } 68 | if (closeDebounceTimeout) { 69 | return defferCall(() => { 70 | closeModal() 71 | if (requestClose) { 72 | requestClose() 73 | } 74 | handleAfterClose() 75 | }, closeDebounceTimeout) 76 | } 77 | closeModal() 78 | if (requestClose) { 79 | requestClose() 80 | } 81 | handleAfterClose() 82 | return true 83 | }, [beforeClose, closeDebounceTimeout, closeModal, handleAfterClose, requestClose]) 84 | 85 | const handleKeyDown = React.useCallback( 86 | (event: KeyboardEvent) => { 87 | if (closeOnEscClick && event.keyCode === ESC_KEY && isInnerOpen) { 88 | event.stopPropagation() 89 | handleClose() 90 | } 91 | }, 92 | [closeOnEscClick, handleClose, isInnerOpen] 93 | ) 94 | 95 | React.useEffect(() => { 96 | document.addEventListener('keydown', handleKeyDown) 97 | return () => { 98 | document.removeEventListener('keydown', handleKeyDown) 99 | } 100 | }, [handleKeyDown]) 101 | 102 | const renderModalContent = React.useCallback(() => { 103 | let content = null 104 | 105 | if (childrenMode) { 106 | content = children 107 | } else if (renderContent) { 108 | content = renderContent() 109 | } 110 | 111 | return ( 112 |
120 | {content || ( 121 | 122 | )} 123 | {renderCloseIcon({ 124 | classes, 125 | closeOnCloseIconClick, 126 | closeIconPosition, 127 | close: handleClose, 128 | renderCloseIconProp 129 | })} 130 |
131 | ) 132 | }, [ 133 | ariaEnabled, 134 | ariaProps, 135 | children, 136 | childrenMode, 137 | classes, 138 | closeIconPosition, 139 | closeOnCloseIconClick, 140 | handleClose, 141 | isFullscreen, 142 | modalContentRef, 143 | renderCloseIconProp, 144 | renderContent 145 | ]) 146 | 147 | const getProps = React.useCallback( 148 | (index, props, length) => { 149 | const { 150 | widthRatio = defaultProps.stackContentSettings.widthRatio, 151 | topOffsetRatio = defaultProps.stackContentSettings.topOffsetRatio, 152 | transition = defaultProps.stackContentSettings.transition, 153 | opacityRatio = defaultProps.stackContentSettings.opacityRatio 154 | } = stackContentSettings 155 | return { 156 | ...props, 157 | style: { 158 | transition, 159 | width: `${100 - (length - index - 1) * widthRatio}%`, 160 | top: `${0 - (length - index - 1) * topOffsetRatio}%`, 161 | opacity: 1 - (length - index - 1) * opacityRatio, 162 | zIndex: 1000 - (length - index - 1) * 10 163 | } 164 | } 165 | }, 166 | [stackContentSettings] 167 | ) 168 | 169 | const renderStackModalWrapper = React.useCallback(() => { 170 | if (children) { 171 | const closeIcon = renderCloseIcon({ 172 | classes, 173 | closeOnCloseIconClick, 174 | closeIconPosition, 175 | close: handleClose, 176 | renderCloseIconProp 177 | }) 178 | return (children as any)({ 179 | classes, 180 | closeIcon, 181 | getProps, 182 | handleClose, 183 | isFullscreen, 184 | modalContentRef, 185 | stackableIndex 186 | }) 187 | } 188 | 189 | return null 190 | }, [ 191 | children, 192 | classes, 193 | closeIconPosition, 194 | closeOnCloseIconClick, 195 | getProps, 196 | handleClose, 197 | isFullscreen, 198 | modalContentRef, 199 | renderCloseIconProp, 200 | stackableIndex 201 | ]) 202 | 203 | const renderModalWrapper = React.useCallback( 204 | () => ( 205 | 206 |
216 | {dimmerEnabled && 217 | renderDimmer({ 218 | classes, 219 | closeOnDimmerClick, 220 | close: handleClose 221 | })} 222 | {stackable ? renderStackModalWrapper() : renderModalContent()} 223 |
224 |
225 | ), 226 | [ 227 | disableScroll, 228 | classes, 229 | closeOnDimmerClick, 230 | dimmerEnabled, 231 | handleClose, 232 | isInnerOpen, 233 | modalWrapperRef, 234 | position, 235 | renderModalContent, 236 | renderStackModalWrapper, 237 | stackable 238 | ] 239 | ) 240 | 241 | const renderModal = React.useCallback(() => { 242 | if (!isInnerOpen && unmountOnClose) { 243 | return null 244 | } 245 | if (portalMode && createPortal) { 246 | const node = portalNode || createElement(classes && classes.portalWrapperClassName) 247 | return createPortal(renderModalWrapper(), node) 248 | } 249 | return renderModalWrapper() 250 | }, [classes, isInnerOpen, portalMode, portalNode, renderModalWrapper, unmountOnClose]) 251 | 252 | return ( 253 | <> 254 | {renderButton({ renderOpenButton, open: openModal })} 255 | {renderModal()} 256 | 257 | ) 258 | } 259 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React hyper modal 2 | #### Fully customizable and accessible modal react component 3 | 4 | Welcome to the react hyper modal repository 😄 5 | I want to introduce you to an awesome react component for displaying modal windows 6 | 7 | ![license](https://img.shields.io/badge/license-MIT-brightgreen.svg) 8 | [![Coverage Status](https://coveralls.io/repos/github/alekseymakhankov/hyper-modal/badge.svg?branch=master&service=github)](https://coveralls.io/github/alekseymakhankov/hyper-modal?branch=master&service=github) 9 | [![Build Status](https://travis-ci.org/alekseymakhankov/hyper-modal.svg?branch=master)](https://travis-ci.org/alekseymakhankov/hyper-modal) 10 | ![min](https://img.shields.io/bundlephobia/min/react-hyper-modal.svg) 11 | ![minzip](https://img.shields.io/bundlephobia/minzip/react-hyper-modal.svg) 12 | 13 | ## [Live demo](https://alekseymakhankov.github.io/packages/?package=hyper-modal) 14 | 15 | ### Check also the new stackable content feature! 16 | 17 | ## Table of contents 18 | 19 | - [Installation](#installation) 20 | - [Usage](#usage) 21 | - [Properties](#properties) 22 | - [Default properties](#default-properties) 23 | - [Types](#types) 24 | - [Contributing](#contributing) 25 | - [License](#license) 26 | 27 | ## Installation 28 | 29 | ###### You can use [![npm](https://api.iconify.design/logos:npm.svg?height=14)](https://www.npmjs.com/get-npm) or [![yarn](https://api.iconify.design/logos:yarn.svg?height=14)](https://yarnpkg.com/lang/en/docs/install) package managers 30 | 31 | ```console 32 | $ npm i --save react-hyper-modal 33 | ``` 34 | __or__ 35 | ```console 36 | $ yarn add react-hyper-modal 37 | ``` 38 | 39 | ## Usage 40 | 41 | #### Controlled modal component 42 | 43 | ```javascript 44 | import React from 'react'; 45 | import HyperModal from 'react-hyper-modal'; 46 | 47 | ... 48 | 49 | class MyComponent extends React.Component { 50 | constructor(props) { 51 | super(props); 52 | this.state = { 53 | isModalOpen: false, 54 | }; 55 | } 56 | 57 | ... 58 | 59 | openModal => this.setState({ isModalOpen: true }); 60 | closeModal => this.setState({ isModalOpen: false }); 61 | 62 | ... 63 | 64 | render() { 65 | const { isModalOpen } = this.state; 66 | return ( 67 | 71 | Your awesome modal content 72 | 73 | ); 74 | } 75 | } 76 | ``` 77 | 78 | #### Uncontrolled modal component 79 | 80 | ```javascript 81 | import React from 'react'; 82 | import HyperModal from 'react-hyper-modal'; 83 | 84 | ... 85 | 86 | const MyComponent = () => { 87 | return ( 88 | { 90 | return ( 91 | 92 | ); 93 | } 94 | /> 95 | ); 96 | } 97 | ``` 98 | 99 | 100 | #### Stackable content example 101 | 102 | To use stackable content you should use `ModalStack` component and children as function. Every child of `ModalStack` will represent a different layout. 103 | 104 | ```javascript 105 | import React from 'react'; 106 | import HyperModal, { ModalStack, ModalStackProps } from 'react-hyper-modal'; 107 | 108 | ... 109 | 110 | const Component = () => { 111 | const [index, setIndex] = React.useState(0) 112 | return ( 113 | ( 117 | 118 | )} 119 | > 120 | {(props: ModalStackProps) => ( 121 | // !!! It's very important to provide props to ModalStack 122 |
123 |
1
124 | 125 | 126 |
127 |
128 |
2
129 | 130 | 131 | 132 |
133 |
134 |
3
135 | 136 | 137 |
138 |
139 | )} 140 |
141 | ) 142 | } 143 | ``` 144 | 145 | ### That's it! 🍰✨ 146 | 147 | ## Properties 148 | You can find props types and default props below the table. 149 | 150 | ##### **\*** - required for controlled modal component 151 | 152 | Props | Description 153 | ------------ | ------------- 154 | afterClose | callback that is called after closing 155 | ariaEnabled | enable [ARIA](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA) properties 156 | ariaProps | custom [ARIA](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA) properties 157 | beforeClose | callback that is called before closing 158 | childrenMode | describing if the modal content should be rendered as [React Children](https://reactjs.org/docs/react-api.html#reactchildren) 159 | classes | overriding default modal class names 160 | closeDebounceTimeout | time to close modal 161 | closeIconPosition | position of close button 162 | closeOnCloseIconClick | close the modal by pressing close button 163 | closeOnDimmerClick | close the modal by pressing on dimmer 164 | closeOnEscClick | close the modal by pressing ESC 165 | dimmerEnabled | describing if the dimmer should be shown or not 166 | isFullscreen | describing if the modal should be shown in full screen or not 167 | isOpen **\*** | describing if the modal should be shown or not 168 | modalContentRef | [reference](https://reactjs.org/docs/refs-and-the-dom.html) to the modal content `div` 169 | modalWrapperRef | [reference](https://reactjs.org/docs/refs-and-the-dom.html) to the modal wrapper `div` 170 | portalMode | describing if the modal should be rendered in [React Portal](https://reactjs.org/docs/portals.html) or not 171 | portalNode | HTML node to create [React Portal](https://reactjs.org/docs/portals.html) 172 | position | setting the modal position 173 | renderCloseIcon | callback for rendering custom close button 174 | renderContent | callback for rendering custom modal content 175 | renderOpenButton | callback or boolean describing if the modal should be uncontrolled component 176 | requestClose **\*** | callback to close the modal 177 | stackable | make content stackable 178 | stackableIndex | stack length 179 | stackContentSettings | stackable content settings 180 | unmountOnClose | describing if the modal should be unmounted when close 181 | 182 | ### Default properties 183 | ```javascript 184 | { 185 | ariaEnabled: true, 186 | ariaProps: { 187 | 'aria-describedby': 'hyper-modal-description', 188 | 'aria-labelledby': 'hyper-modal-title', 189 | role: 'dialog', 190 | }, 191 | disableScroll: true, 192 | childrenMode: true, 193 | closeDebounceTimeout: 0, 194 | closeIconPosition: { 195 | vertical: 'top' as const, 196 | horizontal: 'right' as const, 197 | }, 198 | closeOnCloseIconClick: true, 199 | closeOnDimmerClick: true, 200 | closeOnEscClick: true, 201 | dimmerEnabled: true, 202 | isFullscreen: false, 203 | portalMode: false, 204 | position: { 205 | alignItems: 'center' as const, 206 | justifyContent: 'center' as const, 207 | }, 208 | stackable: false, 209 | stackableIndex: 0, 210 | stackContentSettings: { 211 | widthRatio: 4, 212 | topOffsetRatio: 2, 213 | transition: 'all 0.3s ease', 214 | opacityRatio: 0.2, 215 | } 216 | } 217 | ``` 218 | 219 | ### Types 220 | ```typescript 221 | type TModalPosition = 'flex-start' | 'center' | 'flex-end'; 222 | type THorizontalPosition = 'left' | 'center' | 'right'; 223 | type TVerticalPosition = 'top' | 'middle' | 'bottom'; 224 | 225 | interface IClassNamesProps { 226 | closeIconClassName?: string; 227 | contentClassName?: string; 228 | dimmerClassName?: string; 229 | portalWrapperClassName?: string; 230 | wrapperClassName?: string; 231 | } 232 | 233 | interface IARIAProps { 234 | 'aria-describedby'?: string; 235 | 'aria-labelledby'?: string; 236 | role?: string; 237 | } 238 | 239 | interface IPositionProps { 240 | alignItems?: TModalPosition; 241 | justifyContent?: TModalPosition; 242 | } 243 | 244 | interface ICloseIconPosition { 245 | horizontal?: THorizontalPosition; 246 | vertical?: TVerticalPosition; 247 | } 248 | 249 | interface IModalProps { 250 | afterClose?: () => void; 251 | ariaEnabled?: boolean; 252 | ariaProps?: IARIAProps; 253 | beforeClose?: () => void; 254 | childrenMode?: boolean; 255 | classes?: IClassNamesProps; 256 | closeDebounceTimeout?: number; 257 | closeIconPosition?: ICloseIconPosition; 258 | closeOnCloseIconClick?: boolean; 259 | closeOnDimmerClick?: boolean; 260 | closeOnEscClick?: boolean; 261 | dimmerEnabled?: boolean; 262 | isFullscreen?: boolean; 263 | isOpen: boolean; 264 | modalContentRef?: React.RefObject; 265 | modalWrapperRef?: React.RefObject; 266 | portalMode?: boolean; 267 | portalNode?: HTMLElement; 268 | position?: IPositionProps; 269 | renderCloseIcon?: () => JSX.Element | null | string; 270 | renderContent?: () => JSX.Element | JSX.Element[] | null | string; 271 | renderOpenButton?: boolean | ((requestOpen: () => void) => JSX.Element | string); 272 | requestClose: () => void; 273 | unmountOnClose?: boolean; 274 | } 275 | ``` 276 | 277 | ## Contributing 278 | Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. 279 | 280 | Please make sure to update tests as appropriate. 281 | 282 | ## License 283 | [MIT](https://choosealicense.com/licenses/mit/) 284 | --------------------------------------------------------------------------------