├── .prettierignore ├── github.png ├── src ├── index.ts ├── custom.d.ts ├── components │ ├── utils.ts │ ├── dialog.tsx │ ├── sheet.tsx │ └── drawer.tsx ├── lib │ ├── factory.stories.tsx │ ├── responsive.tsx │ ├── factory.example.tsx │ └── factory.tsx └── tailwind.css ├── postcss.config.js ├── tsconfig.build.json ├── jest ├── jest-setup.js ├── transforms │ ├── cssTransform.js │ └── fileTransform.js └── jest.config.js ├── babel.config.json ├── .prettierrc ├── .storybook ├── preview.ts └── main.ts ├── .gitignore ├── tsconfig.json ├── LICENSE ├── .eslintrc.js ├── CHANGELOG.md ├── .github └── workflows │ └── publish.yml ├── rollup.config.js ├── tailwind.config.js ├── package.json └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/** 2 | dist/** 3 | lib/** -------------------------------------------------------------------------------- /github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lindesvard/pushmodal/HEAD/github.png -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/factory'; 2 | export * from './lib/responsive'; 3 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "dist", "src/**/*.stories.tsx", "src/**/*.test.tsx"] 4 | } 5 | -------------------------------------------------------------------------------- /jest/jest-setup.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import { cleanup } from '@testing-library/react'; 3 | 4 | afterEach(() => { 5 | cleanup(); 6 | }); 7 | -------------------------------------------------------------------------------- /src/custom.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.scss'; 2 | declare module '*.jpg'; 3 | declare module '*.jpeg'; 4 | declare module '*.svg'; 5 | declare module '*.png'; 6 | declare module '*.gif'; 7 | declare module '*.ico'; 8 | -------------------------------------------------------------------------------- /src/components/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx'; 2 | import type { ClassValue } from 'clsx'; 3 | import { twMerge } from 'tailwind-merge'; 4 | 5 | export function cn(...inputs: ClassValue[]) { 6 | return twMerge(clsx(inputs)); 7 | } 8 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | [ 5 | "@babel/preset-react", 6 | { 7 | "runtime": "automatic" 8 | } 9 | ], 10 | "@babel/preset-typescript" 11 | ], 12 | "plugins": [] 13 | } 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 2, 4 | "semi": true, 5 | "bracketSpacing": true, 6 | "singleQuote": true, 7 | "trailingComma": "es5", 8 | "requirePragma": false, 9 | "arrowParens": "always", 10 | "endOfLine": "auto" 11 | } 12 | -------------------------------------------------------------------------------- /.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Preview } from '@storybook/react'; 2 | import '../src/tailwind.css' 3 | 4 | const preview: Preview = { 5 | parameters: { 6 | controls: { 7 | matchers: { 8 | color: /(background|color)$/i, 9 | date: /Date$/i, 10 | }, 11 | }, 12 | }, 13 | }; 14 | 15 | export default preview; 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /dist 4 | /lib 5 | /coverage 6 | .nyc_output 7 | 8 | #docs 9 | /docs 10 | 11 | #idea folder which is the IDE setting files 12 | .idea 13 | .vscode 14 | debug.log 15 | 16 | # misc 17 | .DS_Store 18 | 19 | # analyze webpack output json file 20 | stats.json 21 | 22 | #npmrc because i'm storing my font awesome token inside it 23 | .npmrc 24 | -------------------------------------------------------------------------------- /jest/transforms/cssTransform.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // This is a custom Jest transformer turning style imports into empty objects. 4 | // http://facebook.github.io/jest/docs/en/webpack.html 5 | 6 | module.exports = { 7 | process() { 8 | return 'module.exports = {};'; 9 | }, 10 | getCacheKey() { 11 | // The output is always the same. 12 | return 'cssTransform'; 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /src/lib/factory.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import { FactoryExample } from './factory.example'; 3 | 4 | //👇 This default export determines where your story goes in the story list 5 | const meta: Meta = { 6 | component: FactoryExample, 7 | }; 8 | 9 | export default meta; 10 | type Story = StoryObj; 11 | 12 | export const Factory: Story = {}; 13 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from '@storybook/react-vite'; 2 | 3 | const config: StorybookConfig = { 4 | stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], 5 | addons: [ 6 | '@storybook/addon-links', 7 | '@storybook/addon-essentials', 8 | '@storybook/addon-interactions', 9 | '@storybook/addon-styling-webpack' 10 | ], 11 | framework: { 12 | name: '@storybook/react-vite', 13 | options: {}, 14 | }, 15 | docs: { 16 | autodocs: 'tag', 17 | }, 18 | }; 19 | export default config; 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "skipLibCheck": true, 6 | "esModuleInterop": true, 7 | "noImplicitAny": true, 8 | "noUnusedLocals": true, 9 | "outDir": "dist", 10 | "sourceMap": true, 11 | "allowSyntheticDefaultImports": true, 12 | "declaration": true, 13 | "emitDeclarationOnly": true, 14 | "strict": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "module": "esnext", 18 | "moduleResolution": "node", 19 | "resolveJsonModule": true, 20 | "isolatedModules": true, 21 | "jsx": "react-jsx", 22 | "baseUrl": "./", 23 | "types": ["node", "jest", "@testing-library/jest-dom"] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Carl-Gerhard Lindesvärd 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. -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'eslint:recommended', 4 | 'prettier', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'plugin:react-hooks/recommended', 7 | 'plugin:jest/recommended', 8 | 'plugin:jest/style', 9 | 'plugin:storybook/recommended', 10 | 'plugin:testing-library/react', 11 | 'plugin:jest-dom/recommended', 12 | ], 13 | plugins: ['react', 'jest', 'testing-library', 'jest-dom', 'prettier', '@typescript-eslint'], 14 | parser: '@typescript-eslint/parser', 15 | env: { 16 | browser: true, 17 | node: true, 18 | commonjs: true, 19 | jest: true, 20 | es6: true, 21 | }, 22 | parserOptions: { 23 | ecmaVersion: 6, 24 | sourceType: 'module', 25 | ecmaFeatures: { 26 | jsx: true, 27 | modules: true, 28 | arrowFunctions: true, 29 | restParams: true, 30 | experimentalObjectRestSpread: true, 31 | }, 32 | }, 33 | rules: { 34 | 'prettier/prettier': 'error', 35 | '@typescript-eslint/no-var-requires': 0, 36 | '@typescript-eslint/no-explicit-any': ['off'], 37 | '@typescript-eslint/no-unused-vars': ['warn'], 38 | }, 39 | }; 40 | -------------------------------------------------------------------------------- /jest/transforms/fileTransform.js: -------------------------------------------------------------------------------- 1 | const path = require('path'), 2 | camel = require('to-camel-case'); 3 | 4 | // This is a custom Jest transformer turning file imports into filenames. 5 | // http://facebook.github.io/jest/docs/en/webpack.html 6 | 7 | module.exports = { 8 | process(src, filename) { 9 | const assetFilename = JSON.stringify(path.basename(filename)); 10 | 11 | if (filename.match(/\.svg$/)) { 12 | // Based on how SVGR generates a component name: 13 | // https://github.com/smooth-code/svgr/blob/01b194cf967347d43d4cbe6b434404731b87cf27/packages/core/src/state.js#L6 14 | const pascalCaseFilename = camel(path.parse(filename).name), 15 | componentName = `Svg${pascalCaseFilename}`; 16 | 17 | return `const React = require('react'); 18 | module.exports = { 19 | __esModule: true, 20 | default: ${assetFilename}, 21 | ReactComponent: React.forwardRef(function ${componentName}(props, ref) { 22 | return { 23 | $$typeof: Symbol.for('react.element'), 24 | type: 'svg', 25 | ref: ref, 26 | key: null, 27 | props: Object.assign({}, props, { 28 | children: ${assetFilename} 29 | }) 30 | }; 31 | }), 32 | };`; 33 | } 34 | 35 | return { code: `module.exports = ${assetFilename};` }; 36 | }, 37 | }; 38 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [1.0.3](https://github.com/lindesvard/pushmodal/compare/v1.0.2...v1.0.3) (2024-04-10) 6 | 7 | ### [1.0.2](https://github.com/lindesvard/pushmodal/compare/v1.0.1...v1.0.2) (2024-04-10) 8 | 9 | ### [1.0.1](https://github.com/lindesvard/pushmodal/compare/v0.0.8...v1.0.1) (2024-04-10) 10 | 11 | ### [1.0.0](https://github.com/lindesvard/pushmodal/compare/v0.0.8...v1.0.0) (2024-04-07) 12 | 13 | First stable release of this library 🤤 14 | 15 | ### [0.0.8](https://github.com/lindesvard/pushmodal/compare/v0.0.7...v0.0.8) (2024-04-05) 16 | 17 | 18 | ### Features 19 | 20 | * allow interface + remove second param(props) if component does not require props ([3416efb](https://github.com/lindesvard/pushmodal/commit/3416efbab41e2264e53d157d96cf09a71029e919)) 21 | 22 | ### [0.0.7](https://github.com/lindesvard/pushmodal/compare/v0.0.6...v0.0.7) (2024-04-04) 23 | 24 | ### [0.0.6](https://github.com/lindesvard/pushmodal/compare/v0.0.5...v0.0.6) (2024-04-04) 25 | 26 | ### [0.0.5](https://github.com/lindesvard/pushmodal/compare/v0.0.4...v0.0.5) (2024-04-04) 27 | 28 | 29 | ### Features 30 | 31 | * make props optional on pushModal ([f335d0a](https://github.com/lindesvard/pushmodal/commit/f335d0a150989b40f9f84c53b8a945e0a27dc767)) 32 | 33 | ### [0.0.4](https://github.com/lindesvard/pushmodal/compare/v0.0.3...v0.0.4) (2024-04-03) 34 | 35 | ### [0.0.3](https://github.com/DonAdam2/react-rollup-npm-boilerplate/compare/v0.0.2...v0.0.3) (2024-04-03) 36 | 37 | ### 0.0.2 (2024-04-03) 38 | -------------------------------------------------------------------------------- /src/lib/responsive.tsx: -------------------------------------------------------------------------------- 1 | import { DialogContentProps, DialogProps } from '@radix-ui/react-dialog'; 2 | import { useLayoutEffect, useState } from 'react'; 3 | 4 | type WrapperProps = DialogProps; 5 | type ContentProps = Omit & { 6 | onAnimationEnd?: (...args: any[]) => void; 7 | }; 8 | type Options = { 9 | mobile: { 10 | Wrapper: React.ComponentType; 11 | Content: React.ComponentType; 12 | }; 13 | desktop: { 14 | Wrapper: React.ComponentType; 15 | Content: React.ComponentType; 16 | }; 17 | breakpoint?: number; 18 | }; 19 | 20 | export function createResponsiveWrapper({ mobile, desktop, breakpoint = 640 }: Options) { 21 | function useIsMobile() { 22 | const [isMobile, setIsMobile] = useState(false); 23 | 24 | useLayoutEffect(() => { 25 | const checkDevice = (event: MediaQueryList | MediaQueryListEvent) => { 26 | setIsMobile(event.matches); 27 | }; 28 | 29 | // Initial detection 30 | const mediaQueryList = window.matchMedia(`(max-width: ${breakpoint}px)`); 31 | checkDevice(mediaQueryList); 32 | 33 | // Listener for media query change 34 | mediaQueryList.addEventListener('change', checkDevice); 35 | 36 | // Cleanup listener 37 | return () => { 38 | mediaQueryList.removeEventListener('change', checkDevice); 39 | }; 40 | }, []); 41 | 42 | return isMobile; 43 | } 44 | 45 | function Wrapper(props: WrapperProps) { 46 | const isMobile = useIsMobile(); 47 | return isMobile ? : ; 48 | } 49 | function Content(props: ContentProps) { 50 | const isMobile = useIsMobile(); 51 | return isMobile ? : ; 52 | } 53 | 54 | return { 55 | Wrapper, 56 | Content, 57 | }; 58 | } 59 | -------------------------------------------------------------------------------- /src/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 47.4% 11.2%; 9 | 10 | --muted: 210 40% 96.1%; 11 | --muted-foreground: 215.4 16.3% 46.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 47.4% 11.2%; 15 | 16 | --border: 214.3 31.8% 91.4%; 17 | --input: 214.3 31.8% 91.4%; 18 | 19 | --card: 0 0% 100%; 20 | --card-foreground: 222.2 47.4% 11.2%; 21 | 22 | --primary: 222.2 47.4% 11.2%; 23 | --primary-foreground: 210 40% 98%; 24 | 25 | --secondary: 210 40% 96.1%; 26 | --secondary-foreground: 222.2 47.4% 11.2%; 27 | 28 | --accent: 210 40% 96.1%; 29 | --accent-foreground: 222.2 47.4% 11.2%; 30 | 31 | --destructive: 0 100% 50%; 32 | --destructive-foreground: 210 40% 98%; 33 | 34 | --ring: 215 20.2% 65.1%; 35 | 36 | --radius: 0.5rem; 37 | } 38 | 39 | .dark { 40 | --background: 224 71% 4%; 41 | --foreground: 213 31% 91%; 42 | 43 | --muted: 223 47% 11%; 44 | --muted-foreground: 215.4 16.3% 56.9%; 45 | 46 | --accent: 216 34% 17%; 47 | --accent-foreground: 210 40% 98%; 48 | 49 | --popover: 224 71% 4%; 50 | --popover-foreground: 215 20.2% 65.1%; 51 | 52 | --border: 216 34% 17%; 53 | --input: 216 34% 17%; 54 | 55 | --card: 224 71% 4%; 56 | --card-foreground: 213 31% 91%; 57 | 58 | --primary: 210 40% 98%; 59 | --primary-foreground: 222.2 47.4% 1.2%; 60 | 61 | --secondary: 222.2 47.4% 11.2%; 62 | --secondary-foreground: 210 40% 98%; 63 | 64 | --destructive: 0 63% 31%; 65 | --destructive-foreground: 210 40% 98%; 66 | 67 | --ring: 216 34% 17%; 68 | 69 | --radius: 0.5rem; 70 | } 71 | } 72 | 73 | @layer base { 74 | * { 75 | @apply border-border; 76 | } 77 | body { 78 | @apply bg-background text-foreground; 79 | } 80 | } -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | 10 | jobs: 11 | quality: 12 | 13 | runs-on: ${{ matrix.os }} 14 | 15 | strategy: 16 | matrix: 17 | node-version: [16.x, 17.x, 18.x] 18 | os: [ubuntu-latest, windows-latest] 19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 20 | 21 | steps: 22 | - name: Checkout Frontend Git Repo 23 | uses: actions/checkout@v4 24 | 25 | - name: Setup Node ${{ matrix.node-version }} 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | 30 | - name: Install Packages 31 | run: | 32 | npm install -g pnpm 33 | pnpm install --frozen-lockfile 34 | 35 | - name: Run tests 36 | run: pnpm test 37 | 38 | publish: 39 | runs-on: ubuntu-latest 40 | if: ${{ github.ref == 'refs/heads/main' }} 41 | needs: [quality] 42 | steps: 43 | - name: Checkout Frontend Git Repo 44 | uses: actions/checkout@v4 45 | 46 | - name: Setup Node ${{ matrix.node-version }} 47 | uses: actions/setup-node@v4 48 | with: 49 | node-version: ${{ matrix.node-version }} 50 | registry-url: https://registry.npmjs.org 51 | 52 | - name: Install Packages 53 | run: | 54 | npm install -g pnpm 55 | pnpm install --frozen-lockfile 56 | 57 | - name: Build npm package 58 | run: pnpm build-lib 59 | 60 | - name: publish 61 | run: npm publish --access public 62 | working-directory: lib 63 | env: 64 | NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 65 | -------------------------------------------------------------------------------- /jest/jest.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | // A list of paths to directories that Jest should use to search for files in. 5 | roots: [`/../src`], 6 | // setupFiles before the tests are ran 7 | setupFilesAfterEnv: ['/jest-setup.js'], 8 | // The glob patterns Jest uses to detect test files 9 | testMatch: [ 10 | `/../src/**/__tests__/**/*.{js,jsx,ts,tsx}`, 11 | `/../src/**/*.{spec,test}.{js,jsx,ts,tsx}`, 12 | ], 13 | // The test environment that will be used for testing 14 | testEnvironment: 'jsdom', 15 | // A map from regular expressions to paths to transformers 16 | transform: { 17 | '^.+\\.(js|jsx|mjs|cjs|ts|tsx)$': [ 18 | 'babel-jest', 19 | { configFile: path.join(__dirname, '../babel.config.json') }, 20 | ], 21 | '^.+\\.css$': '/transforms/cssTransform.js', 22 | '^(?!.*\\.(js|jsx|mjs|cjs|ts|tsx|css|json)$)': '/transforms/fileTransform.js', 23 | }, 24 | transformIgnorePatterns: [ 25 | '[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs|cjs|ts|tsx)$', 26 | '^.+\\.(css|sass|scss)$', 27 | ], 28 | modulePaths: [], 29 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources, like images or styles with a single module. 30 | moduleNameMapper: { 31 | // for css modules 32 | '^.+\\.(css|scss)$': 'identity-obj-proxy', 33 | }, 34 | // An array of file extensions your modules use 35 | moduleFileExtensions: ['ts', 'tsx', 'json', 'js', 'jsx', 'node'], 36 | // This option allows you to use custom watch plugins 37 | watchPlugins: ['jest-watch-typeahead/filename', 'jest-watch-typeahead/testname'], 38 | // Automatically reset mock state before every test 39 | resetMocks: true, 40 | // Make calling deprecated APIs throw helpful error messages 41 | errorOnDeprecated: true, 42 | testEnvironmentOptions: { 43 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 44 | url: 'http://localhost', 45 | }, 46 | }; 47 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import typescript from '@rollup/plugin-typescript'; 4 | import clean from '@rollup-extras/plugin-clean'; 5 | import peerDepsExternal from 'rollup-plugin-peer-deps-external'; 6 | import babel from '@rollup/plugin-babel'; 7 | import terser from '@rollup/plugin-terser'; 8 | import postcss from 'rollup-plugin-postcss'; 9 | import autoprefixer from 'autoprefixer'; 10 | import { dts } from 'rollup-plugin-dts'; 11 | 12 | const packageJson = require('./package.json'); 13 | 14 | export default [ 15 | { 16 | input: './src/index.ts', 17 | output: [ 18 | { 19 | file: packageJson.main, 20 | format: 'cjs', 21 | interop: 'compat', 22 | exports: 'named', 23 | sourcemap: true, 24 | inlineDynamicImports: true, 25 | }, 26 | { 27 | file: packageJson.module, 28 | format: 'esm', 29 | exports: 'named', 30 | sourcemap: true, 31 | inlineDynamicImports: true, 32 | }, 33 | ], 34 | plugins: [ 35 | clean('dist'), 36 | peerDepsExternal(), 37 | resolve(), 38 | commonjs(), 39 | babel({ 40 | babelHelpers: 'bundled', 41 | exclude: 'node_modules/**', 42 | }), 43 | typescript({ 44 | tsconfig: './tsconfig.build.json', 45 | }), 46 | terser(), 47 | postcss({ 48 | plugins: [autoprefixer], 49 | modules: { 50 | namedExport: true, 51 | //minify classnames 52 | generateScopedName: '[hash:base64:8]', 53 | }, 54 | //uncomment the following 2 lines if you want to extract styles into a separated file 55 | /*extract: 'styles.css', 56 | inject: false,*/ 57 | minimize: true, 58 | sourceMap: true, 59 | extensions: ['.scss', '.css'], 60 | use: ['sass'], 61 | }), 62 | ], 63 | }, 64 | { 65 | input: 'dist/index.d.ts', 66 | output: [{ file: 'dist/index.d.ts', format: 'es' }], 67 | plugins: [dts()], 68 | }, 69 | ]; 70 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const { fontFamily } = require('tailwindcss/defaultTheme'); 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | darkMode: ['class'], 6 | content: ['src/**/*.{ts,tsx}'], 7 | theme: { 8 | container: { 9 | center: true, 10 | padding: '2rem', 11 | screens: { 12 | '2xl': '1400px', 13 | }, 14 | }, 15 | extend: { 16 | colors: { 17 | border: 'hsl(var(--border))', 18 | input: 'hsl(var(--input))', 19 | ring: 'hsl(var(--ring))', 20 | background: 'hsl(var(--background))', 21 | foreground: 'hsl(var(--foreground))', 22 | primary: { 23 | DEFAULT: 'hsl(var(--primary))', 24 | foreground: 'hsl(var(--primary-foreground))', 25 | }, 26 | secondary: { 27 | DEFAULT: 'hsl(var(--secondary))', 28 | foreground: 'hsl(var(--secondary-foreground))', 29 | }, 30 | destructive: { 31 | DEFAULT: 'hsl(var(--destructive))', 32 | foreground: 'hsl(var(--destructive-foreground))', 33 | }, 34 | muted: { 35 | DEFAULT: 'hsl(var(--muted))', 36 | foreground: 'hsl(var(--muted-foreground))', 37 | }, 38 | accent: { 39 | DEFAULT: 'hsl(var(--accent))', 40 | foreground: 'hsl(var(--accent-foreground))', 41 | }, 42 | popover: { 43 | DEFAULT: 'hsl(var(--popover))', 44 | foreground: 'hsl(var(--popover-foreground))', 45 | }, 46 | card: { 47 | DEFAULT: 'hsl(var(--card))', 48 | foreground: 'hsl(var(--card-foreground))', 49 | }, 50 | }, 51 | borderRadius: { 52 | lg: `var(--radius)`, 53 | md: `calc(var(--radius) - 2px)`, 54 | sm: 'calc(var(--radius) - 4px)', 55 | }, 56 | keyframes: { 57 | 'accordion-down': { 58 | from: { height: '0' }, 59 | to: { height: 'var(--radix-accordion-content-height)' }, 60 | }, 61 | 'accordion-up': { 62 | from: { height: 'var(--radix-accordion-content-height)' }, 63 | to: { height: '0' }, 64 | }, 65 | }, 66 | animation: { 67 | 'accordion-down': 'accordion-down 0.2s ease-out', 68 | 'accordion-up': 'accordion-up 0.2s ease-out', 69 | }, 70 | }, 71 | }, 72 | plugins: [require('tailwindcss-animate')], 73 | }; 74 | -------------------------------------------------------------------------------- /src/components/dialog.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import * as DialogPrimitive from '@radix-ui/react-dialog'; 5 | 6 | import { cn } from './utils'; 7 | 8 | const Dialog = DialogPrimitive.Root; 9 | 10 | const DialogPortal = DialogPrimitive.Portal; 11 | 12 | const DialogOverlay = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, ...props }, ref) => ( 16 | 24 | )); 25 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; 26 | 27 | const DialogContent = React.forwardRef< 28 | React.ElementRef, 29 | React.ComponentPropsWithoutRef 30 | >(({ className, children, ...props }, ref) => ( 31 | 32 | 33 | 41 | {children} 42 | 43 | 54 | 55 | 56 | 57 | Close 58 | 59 | 60 | 61 | )); 62 | DialogContent.displayName = DialogPrimitive.Content.displayName; 63 | 64 | export { Dialog, DialogPortal, DialogOverlay, DialogContent }; 65 | -------------------------------------------------------------------------------- /src/components/sheet.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import * as SheetPrimitive from '@radix-ui/react-dialog'; 5 | import { cva, type VariantProps } from 'class-variance-authority'; 6 | 7 | import { cn } from './utils'; 8 | 9 | const Sheet = SheetPrimitive.Root; 10 | 11 | const SheetPortal = SheetPrimitive.Portal; 12 | 13 | const SheetOverlay = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef 16 | >(({ className, ...props }, ref) => ( 17 | 25 | )); 26 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; 27 | 28 | const sheetVariants = cva( 29 | 'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500', 30 | { 31 | variants: { 32 | side: { 33 | top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top', 34 | bottom: 35 | 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom', 36 | left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm', 37 | right: 38 | 'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm', 39 | }, 40 | }, 41 | defaultVariants: { 42 | side: 'right', 43 | }, 44 | } 45 | ); 46 | 47 | interface SheetContentProps 48 | extends React.ComponentPropsWithoutRef, 49 | VariantProps {} 50 | 51 | const SheetContent = React.forwardRef< 52 | React.ElementRef, 53 | SheetContentProps 54 | >(({ side = 'right', className, children, ...props }, ref) => ( 55 | 56 | 57 | 58 | {children} 59 | 60 | 71 | 72 | 73 | 74 | Close 75 | 76 | 77 | 78 | )); 79 | SheetContent.displayName = SheetPrimitive.Content.displayName; 80 | 81 | export { Sheet, SheetPortal, SheetOverlay, SheetContent }; 82 | -------------------------------------------------------------------------------- /src/components/drawer.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import { Drawer as DrawerPrimitive } from 'vaul'; 5 | 6 | import { cn } from './utils'; 7 | 8 | const Drawer = ({ 9 | shouldScaleBackground = true, 10 | ...props 11 | }: React.ComponentProps) => ( 12 | 13 | ); 14 | Drawer.displayName = 'Drawer'; 15 | 16 | const DrawerTrigger = DrawerPrimitive.Trigger; 17 | 18 | const DrawerPortal = DrawerPrimitive.Portal; 19 | 20 | const DrawerClose = DrawerPrimitive.Close; 21 | 22 | const DrawerOverlay = React.forwardRef< 23 | React.ElementRef, 24 | React.ComponentPropsWithoutRef 25 | >(({ className, ...props }, ref) => ( 26 | 31 | )); 32 | DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName; 33 | 34 | const DrawerContent = React.forwardRef< 35 | React.ElementRef, 36 | React.ComponentPropsWithoutRef 37 | >(({ className, children, ...props }, ref) => ( 38 | 39 | 40 | 48 |
49 | {children} 50 | 51 | 52 | )); 53 | DrawerContent.displayName = 'DrawerContent'; 54 | 55 | const DrawerHeader = ({ className, ...props }: React.HTMLAttributes) => ( 56 |
57 | ); 58 | DrawerHeader.displayName = 'DrawerHeader'; 59 | 60 | const DrawerFooter = ({ className, ...props }: React.HTMLAttributes) => ( 61 |
62 | ); 63 | DrawerFooter.displayName = 'DrawerFooter'; 64 | 65 | const DrawerTitle = React.forwardRef< 66 | React.ElementRef, 67 | React.ComponentPropsWithoutRef 68 | >(({ className, ...props }, ref) => ( 69 | 74 | )); 75 | DrawerTitle.displayName = DrawerPrimitive.Title.displayName; 76 | 77 | const DrawerDescription = React.forwardRef< 78 | React.ElementRef, 79 | React.ComponentPropsWithoutRef 80 | >(({ className, ...props }, ref) => ( 81 | 86 | )); 87 | DrawerDescription.displayName = DrawerPrimitive.Description.displayName; 88 | 89 | export { 90 | Drawer, 91 | DrawerPortal, 92 | DrawerOverlay, 93 | DrawerTrigger, 94 | DrawerClose, 95 | DrawerContent, 96 | DrawerHeader, 97 | DrawerFooter, 98 | DrawerTitle, 99 | DrawerDescription, 100 | }; 101 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pushmodal", 3 | "version": "1.0.3", 4 | "description": "Handle shadcn dialog, sheet and drawer with ease", 5 | "main": "dist/index.cjs.js", 6 | "module": "dist/index.esm.js", 7 | "files": [ 8 | "dist" 9 | ], 10 | "types": "dist/index.d.ts", 11 | "browserslist": { 12 | "production": [ 13 | ">0.2%", 14 | "not dead", 15 | "not op_mini all" 16 | ], 17 | "development": [ 18 | "last 1 chrome version", 19 | "last 1 firefox version", 20 | "last 1 safari version" 21 | ] 22 | }, 23 | "scripts": { 24 | "storybook": "storybook dev -p 6006", 25 | "commit": "git add . && cz", 26 | "semantic-release": "standard-version", 27 | "build-storybook": "storybook build", 28 | "build-lib": "rm -rf lib && mkdir lib && rollup -c --bundleConfigAsCjs && cp -r ./dist ./lib && cp package.json LICENSE README.md ./lib", 29 | "test": "jest -c jest/jest.config.js --verbose --passWithNoTests", 30 | "test:watch": "pnpm run test --watch", 31 | "test:clear": "pnpm run test --clearCache" 32 | }, 33 | "repository": { 34 | "type": "git", 35 | "url": "git+https://github.com/lindesvard/pushmodal.git" 36 | }, 37 | "keywords": [ 38 | "react", 39 | "button" 40 | ], 41 | "author": "Carl Lindesvärd (https://git.new/carl)", 42 | "license": "MIT", 43 | "bugs": { 44 | "url": "https://github.com/lindesvard/pushmodal/issues" 45 | }, 46 | "homepage": "https://git.new/pushmodal", 47 | "devDependencies": { 48 | "@babel/core": "7.24.0", 49 | "@babel/preset-react": "7.23.3", 50 | "@babel/preset-typescript": "7.23.3", 51 | "@radix-ui/react-dialog": "^1.0.5", 52 | "@rollup-extras/plugin-clean": "1.3.9", 53 | "@rollup/plugin-babel": "6.0.4", 54 | "@rollup/plugin-commonjs": "25.0.7", 55 | "@rollup/plugin-node-resolve": "15.2.3", 56 | "@rollup/plugin-terser": "0.4.4", 57 | "@rollup/plugin-typescript": "11.1.6", 58 | "@storybook/addon-actions": "8.0.0", 59 | "@storybook/addon-essentials": "8.0.0", 60 | "@storybook/addon-interactions": "8.0.0", 61 | "@storybook/addon-links": "8.0.0", 62 | "@storybook/addon-styling-webpack": "^1.0.0", 63 | "@storybook/blocks": "8.0.0", 64 | "@storybook/preset-scss": "1.0.3", 65 | "@storybook/react": "8.0.0", 66 | "@storybook/react-vite": "8.0.0", 67 | "@storybook/test": "8.0.0", 68 | "@types/jest": "29.5.12", 69 | "@types/node": "20.11.28", 70 | "@types/react": "18.2.66", 71 | "@types/react-dom": "18.2.22", 72 | "@types/react-test-renderer": "18.0.7", 73 | "@typescript-eslint/eslint-plugin": "7.2.0", 74 | "@typescript-eslint/parser": "7.2.0", 75 | "autoprefixer": "10.4.18", 76 | "babel-jest": "29.7.0", 77 | "babel-loader": "9.1.3", 78 | "class-variance-authority": "^0.7.0", 79 | "clsx": "^2.0.0", 80 | "commitizen": "4.3.0", 81 | "css-loader": "6.10.0", 82 | "cz-conventional-changelog": "3.3.0", 83 | "eslint": "8.57.0", 84 | "eslint-config-prettier": "9.1.0", 85 | "eslint-plugin-jest": "27.9.0", 86 | "eslint-plugin-jest-dom": "5.1.0", 87 | "eslint-plugin-prettier": "5.1.3", 88 | "eslint-plugin-react": "7.34.1", 89 | "eslint-plugin-react-hooks": "4.6.0", 90 | "eslint-plugin-storybook": "0.8.0", 91 | "eslint-plugin-testing-library": "6.2.0", 92 | "identity-obj-proxy": "3.0.0", 93 | "jest": "29.7.0", 94 | "jest-environment-jsdom": "29.7.0", 95 | "jest-resolve": "29.7.0", 96 | "jest-watch-typeahead": "2.2.2", 97 | "postcss": "8.4.35", 98 | "prettier": "3.2.5", 99 | "react": "18.2.0", 100 | "react-dom": "18.2.0", 101 | "react-test-renderer": "18.2.0", 102 | "rollup": "4.13.0", 103 | "rollup-plugin-dts": "6.1.0", 104 | "rollup-plugin-peer-deps-external": "2.2.4", 105 | "rollup-plugin-postcss": "4.0.2", 106 | "sass": "1.72.0", 107 | "sass-loader": "14.1.1", 108 | "standard-version": "9.5.0", 109 | "storybook": "8.0.0", 110 | "style-loader": "3.3.4", 111 | "tailwind-merge": "^1.0.0 || ^2.0.0", 112 | "tailwindcss": "^3.4.3", 113 | "tailwindcss-animate": "^1.0.7", 114 | "to-camel-case": "1.0.0", 115 | "tslib": "2.6.2", 116 | "typescript": "5.4.2", 117 | "vaul": "^0.9.0" 118 | }, 119 | "peerDependencies": { 120 | "@radix-ui/react-dialog": "^1.0.0", 121 | "react": "^16.12.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0 || ^21.0.0", 122 | "react-dom": "^16.12.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0 || ^21.0.0" 123 | }, 124 | "config": { 125 | "commitizen": { 126 | "path": "./node_modules/cz-conventional-changelog" 127 | } 128 | }, 129 | "dependencies": { 130 | "mitt": "^3.0.1" 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/lib/factory.example.tsx: -------------------------------------------------------------------------------- 1 | import { createPushModal, createResponsiveWrapper } from '../'; 2 | import { SheetContent } from '../components/sheet'; 3 | import { Dialog, DialogContent } from '../components/dialog'; 4 | import { Drawer, DrawerContent } from '../components/drawer'; 5 | import { useEffect } from 'react'; 6 | 7 | const Responsive = createResponsiveWrapper({ 8 | desktop: { 9 | Wrapper: Dialog, 10 | Content: DialogContent, 11 | }, 12 | mobile: { 13 | Wrapper: Drawer, 14 | Content: DrawerContent, 15 | }, 16 | breakpoint: 640, 17 | }); 18 | 19 | const { onPushModal, useOnPushModal, pushModal, popAllModals, replaceWithModal, ModalProvider } = 20 | createPushModal({ 21 | modals: { 22 | ModalExample: () => { 23 | useEffect(() => { 24 | console.log('Mount ModalExample'); 25 | return () => { 26 | console.log('Unmount ModalExample'); 27 | }; 28 | }, []); 29 | return ( 30 | 31 |
32 | 38 | 44 | 50 | 56 |
57 |
58 | ); 59 | }, 60 | SheetExample: () => { 61 | return ( 62 | 63 |
64 | 70 | 76 | 82 |
83 |
84 | ); 85 | }, 86 | DrawerExample: { 87 | Component: () => Drawer, 88 | Wrapper: Drawer, 89 | }, 90 | DrawerExampleWithProps: { 91 | Component: ({ int }: { int: number }) => Drawer, 92 | Wrapper: Drawer, 93 | }, 94 | WithProps: (props: { num: number; str: string; bool: boolean }) => ( 95 | 96 |
{JSON.stringify(props, null, 2)}
97 |
98 | ), 99 | Dynamic: { 100 | Component: () => ( 101 | 102 | Dynamic modal (Drawer on mobile and Dialog on desktop) 103 | 104 | ), 105 | Wrapper: Responsive.Wrapper, 106 | }, 107 | }, 108 | }); 109 | 110 | export function FactoryExample() { 111 | useEffect(() => { 112 | onPushModal('WithProps', (open, props, name) => { 113 | console.log('useEffect - onPushModal', { 114 | name, 115 | open, 116 | props, 117 | }); 118 | }); 119 | }, []); 120 | 121 | useOnPushModal('ModalExample', (open, props, name) => { 122 | console.log('useOnPushModal - ModalExample', { open, props, name }); 123 | }); 124 | 125 | useOnPushModal('*', (open, props, name) => { 126 | console.log('useOnPushModal - *', { open, props, name }); 127 | }); 128 | 129 | // This will never happen, just testing types 130 | if (!ModalProvider) { 131 | // eslint-disable-next-line 132 | // @ts-expect-error 133 | pushModal('DrawerExampleWithProps'); 134 | 135 | pushModal('DrawerExampleWithProps', { 136 | int: 1, 137 | }); 138 | 139 | pushModal('DrawerExample'); 140 | 141 | pushModal('ModalExample'); 142 | 143 | pushModal('SheetExample'); 144 | 145 | // eslint-disable-next-line 146 | // @ts-expect-error 147 | pushModal('WithProps'); 148 | 149 | pushModal('WithProps', { 150 | num: 1, 151 | str: 'string', 152 | bool: true, 153 | }); 154 | } 155 | 156 | return ( 157 |
158 | 159 |
160 | 166 | 172 | 178 | 184 | 196 |
197 |
198 | ); 199 | } 200 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![hero](github.png) 2 |
3 |
4 | 5 | ## Installation 6 | 7 | ```bash 8 | pnpm add pushmodal 9 | ``` 10 | 11 | > We take for granted that you already have `@radix-ui/react-dialog` installed. If not ➡️ `pnpm add @radix-ui/react-dialog` 12 | 13 | ## Usage 14 | 15 | #### 1. Create a modal 16 | 17 | When creating a dialog/sheet/drawer you need to wrap your component with the `<(Dialog|Sheet|Drawer)Content>` component. But skip the `Root` since we do that for you. 18 | 19 | ```tsx 20 | // file: src/modals/modal-example.tsx 21 | import { DialogContent } from '@/ui/dialog' // shadcn dialog 22 | 23 | // or any of the below 24 | // import { SheetContent } from '@/ui/sheet' // shadcn sheet 25 | // import { DrawerContent } from '@/ui/drawer' // shadcn drawer 26 | 27 | export default function ModalExample({ foo }: { foo: string }) { 28 | return ( 29 | 30 | Your modal 31 | 32 | ) 33 | } 34 | ``` 35 | 36 | 37 | #### 2. Initialize your modals 38 | 39 | ```tsx 40 | // file: src/modals/index.tsx (alias '@/modals') 41 | import ModalExample from './modal-example' 42 | import SheetExample from './sheet-example' 43 | import DrawerExample from './drawer-examle' 44 | import { createPushModal } from 'pushmodal' 45 | import { Drawer } from '@/ui/drawer' // shadcn drawer 46 | 47 | export const { 48 | pushModal, 49 | popModal, 50 | popAllModals, 51 | replaceWithModal, 52 | useOnPushModal, 53 | onPushModal, 54 | ModalProvider 55 | } = createPushModal({ 56 | modals: { 57 | // Short hand 58 | ModalExample, 59 | SheetExample, 60 | 61 | // Longer definition where you can choose what wrapper you want 62 | // Only needed if you don't want `Dialog.Root` from '@radix-ui/react-dialog' 63 | // shadcn drawer needs a custom Wrapper 64 | DrawerExample: { 65 | Wrapper: Drawer, 66 | Component: DrawerExample 67 | } 68 | }, 69 | }) 70 | ``` 71 | 72 | How we usually structure things 73 | 74 | ```md 75 | src 76 | ├── ... 77 | ├── modals 78 | │ ├── modal-example.tsx 79 | │ ├── sheet-example.tsx 80 | │ ├── drawer-examle.tsx 81 | │ ├── ... more modals here ... 82 | │ └── index.tsx 83 | ├── ... 84 | └── ... 85 | ``` 86 | 87 | #### 3. Add the `` to your root file. 88 | 89 | ```ts 90 | import { ModalProvider } from '@/modals' 91 | 92 | export default function App({ children }: { children: React.ReactNode }) { 93 | return ( 94 | <> 95 |   {/* Notice! You should not wrap your children */} 96 | 97 | {children} 98 | 99 | ) 100 | } 101 | ``` 102 | 103 | #### 4. Use `pushModal` 104 | 105 | `pushModal` can have 1-2 arguments 106 | 107 | 1. `name` - name of your modal 108 | 2. `props` (might be optional) - props for your modal, types are infered from your component! 109 | 110 | ```tsx 111 | import { pushModal } from '@/modals' 112 | 113 | export default function RandomComponent() { 114 | return ( 115 |
116 | 119 | 122 | 125 |
126 | ) 127 | } 128 | ``` 129 | 130 | #### 4. Closing modals 131 | 132 | You can close a modal in three different ways: 133 | 134 | - `popModal()` - will pop the last added modal 135 | - `popModal('Modal1')` - will pop the last added modal with name `Modal1` 136 | - `popAllModals()` - will close all your modals 137 | 138 | #### 5. Replacing current modal 139 | 140 | Replace the last pushed modal. Same interface as `pushModal`. 141 | 142 | ```ts 143 | replaceWithModal('SheetExample', { /* Props if any */ }) 144 | ``` 145 | 146 | #### 6. Using events 147 | 148 | You can listen to events with `useOnPushModal` (inside react component) or `onPushModal` (or globally). 149 | 150 | The event receive the state of the modal (open/closed), the modals name and props. You can listen to all modal changes with `*` or provide a name of the modal you want to listen on. 151 | 152 | **Inside a component** 153 | 154 | ```tsx 155 | import { useCallback } from 'react' 156 | import { useOnPushModal } from '@/modals' 157 | 158 | // file: a-react-component.tsx 159 | export default function ReactComponent() { 160 | // listen to any modal open/close 161 | useOnPushModal('*', 162 | useCallback((open, props, name) => { 163 | console.log('is open?', open); 164 | console.log('props from component', props); 165 | console.log('name', name); 166 | }, []) 167 | ) 168 | 169 | // listen to `ModalExample` open/close 170 | useOnPushModal('ModalExample', 171 | useCallback((open, props) => { 172 | console.log('is `ModalExample` open?', open); 173 | console.log('props for ModalExample', props); 174 | }, []) 175 | ) 176 | } 177 | ``` 178 | 179 | **Globally** 180 | 181 | ```ts 182 | import { onPushModal } from '@/modals' 183 | 184 | const unsub = onPushModal('*', (open, props, name) => { 185 | // do stuff 186 | }) 187 | ``` 188 | 189 | #### Responsive rendering (mobile/desktop) 190 | 191 | In some cases you want to show a drawer on mobile and a dialog on desktop. This is possible and we have created a helper function to get you going faster. `createResponsiveWrapper` 💪 192 | 193 | ```tsx 194 | // path: src/modals/dynamic.tsx 195 | import { createResponsiveWrapper } from 'pushmodal' 196 | import { Dialog, DialogContent } from '@/ui/dialog'; // shadcn dialog 197 | import { Drawer, DrawerContent } from '@/ui/drawer'; // shadcn drawer 198 | 199 | export default createResponsiveWrapper({ 200 | desktop: { 201 | Wrapper: Dialog, 202 | Content: DialogContent, 203 | }, 204 | mobile: { 205 | Wrapper: Drawer, 206 | Content: DrawerContent, 207 | }, 208 | breakpoint: 640, 209 | }); 210 | 211 | // path: src/modals/your-modal.tsx 212 | import * as Dynamic from './dynamic' 213 | 214 | export default function YourModal() { 215 | return ( 216 | 217 | Drawer in mobile and dialog on desktop 🤘 218 | 219 | ) 220 | } 221 | 222 | // path: src/modals/index.ts 223 | import * as Dynamic from './dynamic' 224 | import YourModal from './your-modal' 225 | import { createPushModal } from 'pushmodal' 226 | 227 | export const { 228 | pushModal, 229 | popModal, 230 | popAllModals, 231 | replaceWithModal, 232 | useOnPushModal, 233 | onPushModal, 234 | ModalProvider 235 | } = createPushModal({ 236 | modals: { 237 | YourModal: { 238 | Wrapper: Dynamic.Wrapper, 239 | Component: YourModal 240 | } 241 | }, 242 | }) 243 | ``` 244 | 245 | ## Issues / Limitations 246 | 247 | Issues or limitations will be listed here. 248 | 249 | ## Contributors 250 | 251 | - [lindesvard](https://github.com/lindesvard) 252 | - [nicholascostadev](https://github.com/nicholascostadev) -------------------------------------------------------------------------------- /src/lib/factory.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { Suspense, useEffect, useState } from 'react'; 4 | import mitt, { Handler } from 'mitt'; 5 | import * as Dialog from '@radix-ui/react-dialog'; 6 | 7 | interface CreatePushModalOptions { 8 | modals: { 9 | [key in keyof T]: 10 | | { 11 | Wrapper: React.ComponentType<{ 12 | open: boolean; 13 | onOpenChange: (open?: boolean) => void; 14 | children: React.ReactNode; 15 | defaultOpen?: boolean; 16 | }>; 17 | Component: React.ComponentType; 18 | } 19 | | React.ComponentType; 20 | }; 21 | } 22 | 23 | export function createPushModal({ modals }: CreatePushModalOptions) { 24 | type Modals = typeof modals; 25 | type ModalKeys = keyof Modals; 26 | 27 | type EventHandlers = { 28 | change: { name: ModalKeys; open: boolean; props: Record }; 29 | push: { 30 | name: ModalKeys; 31 | props: Record; 32 | }; 33 | replace: { 34 | name: ModalKeys; 35 | props: Record; 36 | }; 37 | pop: { name?: ModalKeys }; 38 | popAll: undefined; 39 | }; 40 | 41 | interface StateItem { 42 | key: string; 43 | name: ModalKeys; 44 | props: Record; 45 | open: boolean; 46 | closedAt?: number; 47 | } 48 | 49 | const filterGarbage = (item: StateItem): boolean => { 50 | if (item.open || !item.closedAt) { 51 | return true; 52 | } 53 | return Date.now() - item.closedAt < 300; 54 | }; 55 | 56 | const emitter = mitt(); 57 | 58 | function ModalProvider() { 59 | const [state, setState] = useState([]); 60 | 61 | // Run this to ensure we remove closed modals from the state 62 | // Otherwise the unmount in useEffect will not be triggered until the next modal is opened 63 | useEffect(() => { 64 | const hasClosedModals = state.some((item) => typeof item.closedAt === 'number'); 65 | let timer: NodeJS.Timeout | undefined; 66 | if (hasClosedModals) { 67 | timer = setInterval(() => { 68 | setState((p) => [...p.filter(filterGarbage)]); 69 | }, 100); 70 | } else { 71 | clearInterval(timer); 72 | } 73 | 74 | return () => { 75 | clearInterval(timer); 76 | }; 77 | }, [state]); 78 | 79 | useEffect(() => { 80 | const pushHandler: Handler = ({ name, props }) => { 81 | emitter.emit('change', { name, open: true, props }); 82 | setState((p) => 83 | [ 84 | ...p, 85 | { 86 | key: Math.random().toString(), 87 | name, 88 | props, 89 | open: true, 90 | }, 91 | ].filter(filterGarbage) 92 | ); 93 | }; 94 | const replaceHandler: Handler = ({ name, props }) => { 95 | setState((p) => { 96 | // find last item to replace 97 | const last = p.findLast((item) => item.open); 98 | if (last) { 99 | // if found emit close event 100 | emitter.emit('change', { name: last.name, open: false, props: last.props }); 101 | } 102 | emitter.emit('change', { name, open: true, props }); 103 | 104 | return [ 105 | // 1) close last item 2) filter garbage 3) add new item 106 | ...p 107 | .map((item) => { 108 | if (item.key === last?.key) { 109 | return { ...item, open: false, closedAt: Date.now() }; 110 | } 111 | return item; 112 | }) 113 | .filter(filterGarbage), 114 | { 115 | key: Math.random().toString(), 116 | name, 117 | props, 118 | open: true, 119 | }, 120 | ]; 121 | }); 122 | }; 123 | 124 | const popHandler: Handler = ({ name }) => { 125 | setState((items) => { 126 | // Find last index 127 | const index = 128 | name === undefined 129 | ? // Pick last item if no name is provided 130 | items.length - 1 131 | : items.findLastIndex((item) => item.name === name && item.open); 132 | const match = items[index]; 133 | if (match) { 134 | emitter.emit('change', { 135 | name: match.name, 136 | open: false, 137 | props: match.props, 138 | }); 139 | } 140 | return items.map((item) => 141 | match?.key !== item.key ? item : { ...item, open: false, closedAt: Date.now() } 142 | ); 143 | }); 144 | }; 145 | 146 | const popAllHandler: Handler = () => { 147 | setState((items) => items.map((item) => ({ ...item, open: false, closedAt: Date.now() }))); 148 | }; 149 | emitter.on('push', pushHandler); 150 | emitter.on('replace', replaceHandler); 151 | emitter.on('pop', popHandler); 152 | emitter.on('popAll', popAllHandler); 153 | return () => { 154 | emitter.off('push', pushHandler); 155 | emitter.off('replace', replaceHandler); 156 | emitter.off('pop', popHandler); 157 | emitter.off('popAll', popAllHandler); 158 | }; 159 | }, [state.length]); 160 | return ( 161 | <> 162 | {state.map((item) => { 163 | const modal = modals[item.name]; 164 | const Component = 165 | 'Component' in modal ? modal.Component : (modal as React.ComponentType); 166 | const Root = 'Wrapper' in modal ? modal.Wrapper : Dialog.Root; 167 | 168 | return ( 169 | { 173 | if (!isOpen) { 174 | popModal(item.name); 175 | } 176 | }} 177 | defaultOpen 178 | > 179 | 180 | 181 | 182 | 183 | ); 184 | })} 185 | 186 | ); 187 | } 188 | 189 | type Prettify = { 190 | [K in keyof T]: T[K]; 191 | // eslint-disable-next-line @typescript-eslint/ban-types 192 | } & {}; 193 | type GetComponentProps = T extends 194 | | React.ComponentType 195 | | React.Component 196 | | { Component: React.ComponentType } 197 | ? P 198 | : never; 199 | type IsObject = 200 | Prettify extends Record ? Prettify : never; 201 | type HasKeys = keyof T extends never ? never : T; 202 | 203 | const pushModal = >>( 204 | name: T, 205 | ...args: HasKeys> extends never 206 | ? // No props provided 207 | [] 208 | : // Props provided 209 | [props: B] 210 | ) => { 211 | const [props] = args; 212 | return emitter.emit('push', { 213 | name, 214 | props: props ?? {}, 215 | }); 216 | }; 217 | 218 | const popModal = (name?: StateItem['name']) => 219 | emitter.emit('pop', { 220 | name, 221 | }); 222 | 223 | const replaceWithModal = >( 224 | name: T, 225 | ...args: HasKeys> extends never 226 | ? // No props provided 227 | [] 228 | : // Props provided 229 | [props: B] 230 | ) => { 231 | const [props] = args; 232 | emitter.emit('replace', { 233 | name, 234 | props: props ?? {}, 235 | }); 236 | }; 237 | 238 | const popAllModals = () => emitter.emit('popAll'); 239 | 240 | type EventCallback = ( 241 | open: boolean, 242 | props: GetComponentProps, 243 | name?: T 244 | ) => void; 245 | 246 | const onPushModal = (name: T | '*', callback: EventCallback) => { 247 | const fn: Handler = (payload) => { 248 | if (payload.name === name) { 249 | callback(payload.open, payload.props as GetComponentProps, payload.name as T); 250 | } else if (name === '*') { 251 | callback(payload.open, payload.props as any, payload.name as T); 252 | } 253 | }; 254 | emitter.on('change', fn); 255 | return () => emitter.off('change', fn); 256 | }; 257 | 258 | return { 259 | ModalProvider, 260 | pushModal, 261 | popModal, 262 | popAllModals, 263 | replaceWithModal, 264 | onPushModal, 265 | useOnPushModal: (name: T | '*', callback: EventCallback) => { 266 | useEffect(() => { 267 | return onPushModal(name, callback); 268 | }, [name, callback]); 269 | }, 270 | }; 271 | } 272 | --------------------------------------------------------------------------------