├── vue ├── src │ ├── useModal.js │ ├── CloseButton.vue │ ├── ModalRenderer.vue │ ├── Deferred.vue │ ├── inertiauiModal.js │ ├── config.js │ ├── WhenVisible.vue │ ├── ModalRoot.vue │ ├── ModalContent.vue │ ├── SlideoverContent.vue │ ├── ModalLink.vue │ ├── helpers.js │ ├── Modal.vue │ ├── HeadlessModal.vue │ └── modalStack.js ├── vitest.config.js ├── .prettierrc ├── .eslintrc.cjs ├── vite.config.js ├── package.json └── tests │ ├── config.test.js │ ├── helpers.test.js │ └── modalStack.test.js ├── react ├── src │ ├── config.js │ ├── useModal.js │ ├── helpers.js │ ├── Deferred.jsx │ ├── CloseButton.jsx │ ├── inertiauiModal.js │ ├── ModalRenderer.jsx │ ├── WhenVisible.jsx │ ├── ModalContent.jsx │ ├── SlideoverContent.jsx │ ├── ModalLink.jsx │ ├── Modal.jsx │ ├── HeadlessModal.jsx │ └── ModalRoot.jsx ├── .prettierrc ├── .eslintrc.cjs ├── vite.config.js └── package.json ├── config └── inertiaui-modal.php ├── src ├── ModalType.php ├── QueryStringArrayFormat.php ├── ModalPosition.php ├── Support.php ├── helpers.php ├── Redirector.php ├── Testing │ └── DuskModalMacros.php ├── ModalVisit.php ├── DispatchBaseUrlRequest.php ├── ModalServiceProvider.php ├── ModalConfig.php └── Modal.php ├── .prettierrc ├── install-dev.sh ├── rector.php ├── LICENSE.md ├── composer.json ├── README.md └── CHANGELOG.md /vue/src/useModal.js: -------------------------------------------------------------------------------- 1 | import { inject, toValue } from 'vue' 2 | 3 | export default function useModal() { 4 | return toValue(inject('modalContext', null)) 5 | } 6 | -------------------------------------------------------------------------------- /react/src/config.js: -------------------------------------------------------------------------------- 1 | import { resetConfig, putConfig, getConfig, getConfigByType } from './../../vue/src/config.js' 2 | 3 | export { resetConfig, putConfig, getConfig, getConfigByType } 4 | -------------------------------------------------------------------------------- /vue/vitest.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | 4 | export default defineConfig({ 5 | plugins: [vue()], 6 | test: { globals: true }, 7 | }) 8 | -------------------------------------------------------------------------------- /config/inertiaui-modal.php: -------------------------------------------------------------------------------- 1 | true, 8 | ]; 9 | -------------------------------------------------------------------------------- /src/ModalType.php: -------------------------------------------------------------------------------- 1 | back($status, $headers, $fallback); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /vue/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['eslint:recommended', 'plugin:vue/vue3-recommended', 'plugin:tailwindcss/recommended', 'prettier'], 3 | plugins: ['prettier', 'unused-imports'], 4 | parserOptions: { 5 | sourceType: 'module', 6 | ecmaVersion: 2022, 7 | }, 8 | rules: { 9 | 'prettier/prettier': ['error', { usePrettierrc: true }], 10 | 'vue/require-default-prop': 0, 11 | 'vue/no-v-html': 'off', 12 | 'vue/multi-word-component-names': 'off', 13 | 'vue/no-parsing-error': 'warn', 14 | 'tailwindcss/no-custom-classname': 'off', 15 | }, 16 | parser: 'vue-eslint-parser', 17 | } 18 | -------------------------------------------------------------------------------- /vue/src/CloseButton.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 25 | -------------------------------------------------------------------------------- /vue/vite.config.js: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path' 2 | import { defineConfig } from 'vite' 3 | import vue from '@vitejs/plugin-vue' 4 | import eslintPlugin from 'vite-plugin-eslint' 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [ 9 | eslintPlugin({ 10 | fix: true, 11 | failOnError: false, 12 | }), 13 | vue(), 14 | ], 15 | 16 | build: { 17 | lib: { 18 | entry: [resolve(__dirname, 'src/inertiauiModal.js')], 19 | name: 'InertiaUIModal', 20 | fileName: 'inertiaui-modal', 21 | cssFileName: 'style', 22 | }, 23 | rollupOptions: { 24 | external: ['@inertiajs/core', '@inertiajs/vue3', 'axios', 'vue'], 25 | }, 26 | }, 27 | }) 28 | -------------------------------------------------------------------------------- /vue/src/ModalRenderer.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 29 | -------------------------------------------------------------------------------- /react/src/Deferred.jsx: -------------------------------------------------------------------------------- 1 | // See: https://github.com/inertiajs/inertia/blob/48bcd21fb7daf467d0df1bfde2408f161f94a579/packages/react/src/Deferred.ts 2 | import { useEffect, useState } from 'react' 3 | import useModal from './useModal' 4 | 5 | const Deferred = ({ children, data, fallback }) => { 6 | if (!data) { 7 | throw new Error('`` requires a `data` prop to be a string or array of strings') 8 | } 9 | 10 | const [loaded, setLoaded] = useState(false) 11 | const keys = Array.isArray(data) ? data : [data] 12 | const modalProps = useModal().props 13 | 14 | useEffect(() => { 15 | setLoaded(keys.every((key) => modalProps[key] !== undefined)) 16 | }, [modalProps, keys]) 17 | 18 | return loaded ? children : fallback 19 | } 20 | 21 | Deferred.displayName = 'InertiaModalDeferred' 22 | 23 | export default Deferred 24 | -------------------------------------------------------------------------------- /react/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'eslint:recommended', 4 | 'plugin:react/recommended', 5 | 'plugin:react/jsx-runtime', 6 | 'plugin:react-hooks/recommended', 7 | 'plugin:tailwindcss/recommended', 8 | 'prettier', 9 | ], 10 | plugins: ['react', 'react-hooks', 'prettier', 'unused-imports'], 11 | parserOptions: { 12 | sourceType: 'module', 13 | ecmaVersion: 2022, 14 | }, 15 | rules: { 16 | 'react/prop-types': 0, 17 | 'react-hooks/exhaustive-deps': 'off', 18 | 'prettier/prettier': ['error', { usePrettierrc: true }], 19 | 'tailwindcss/no-custom-classname': 'off', 20 | }, 21 | env: { 22 | browser: true, 23 | es2022: true, 24 | }, 25 | settings: { 26 | react: { 27 | version: 'detect', 28 | }, 29 | }, 30 | } 31 | -------------------------------------------------------------------------------- /react/src/CloseButton.jsx: -------------------------------------------------------------------------------- 1 | export default function CloseButton({ onClick }) { 2 | return ( 3 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /vue/src/Deferred.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 33 | -------------------------------------------------------------------------------- /react/vite.config.js: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path' 2 | import { defineConfig } from 'vite' 3 | import react from '@vitejs/plugin-react' 4 | import eslintPlugin from 'vite-plugin-eslint' 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [ 9 | // eslintPlugin({ 10 | // fix: true, 11 | // failOnError: false, 12 | // }), 13 | react(), 14 | ], 15 | 16 | build: { 17 | minify: process.env.NODE_ENV === 'production', 18 | lib: { 19 | entry: [resolve(__dirname, 'src/inertiauiModal.js')], 20 | name: 'InertiaUIModal', 21 | fileName: 'inertiaui-modal', 22 | cssFileName: 'style', 23 | }, 24 | rollupOptions: { 25 | external: ['@inertiajs/core', '@inertiajs/react', 'axios', 'react', 'react-dom', 'react/jsx-runtime'], 26 | }, 27 | }, 28 | }) 29 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | withPaths([ 11 | __DIR__.'/src', 12 | ]) 13 | ->withSkip([ 14 | __DIR__.'/src/TableServiceProvider.php', 15 | ]) 16 | ->withSkip([ 17 | AddOverrideAttributeToOverriddenMethodsRector::class, 18 | ]) 19 | ->withPreparedSets( 20 | deadCode: true, 21 | codeQuality: true, 22 | codingStyle: true, 23 | typeDeclarations: true, 24 | privatization: true, 25 | instanceOf: true, 26 | earlyReturn: true, 27 | strictBooleans: true, 28 | ) 29 | ->withRules([ 30 | DeclareStrictTypesRector::class, 31 | ]) 32 | ->withPhpSets(); 33 | -------------------------------------------------------------------------------- /react/src/inertiauiModal.js: -------------------------------------------------------------------------------- 1 | import { createElement } from 'react' 2 | import { getConfig, putConfig, resetConfig } from './config.js' 3 | import { useModalIndex } from './ModalRenderer.jsx' 4 | import { useModalStack, ModalRoot, ModalStackProvider, renderApp, initFromPageProps } from './ModalRoot.jsx' 5 | import useModal from './useModal.js' 6 | import Deferred from './Deferred.jsx' 7 | import HeadlessModal from './HeadlessModal.jsx' 8 | import Modal from './Modal.jsx' 9 | import ModalLink from './ModalLink.jsx' 10 | import WhenVisible from './WhenVisible.jsx' 11 | 12 | const setPageLayout = (layout) => (module) => { 13 | module.default.layout = (page) => createElement(layout, {}, page) 14 | return module 15 | } 16 | 17 | export { 18 | Deferred, 19 | HeadlessModal, 20 | Modal, 21 | ModalLink, 22 | ModalRoot, 23 | ModalStackProvider, 24 | WhenVisible, 25 | getConfig, 26 | initFromPageProps, 27 | putConfig, 28 | renderApp, 29 | resetConfig, 30 | setPageLayout, 31 | useModal, 32 | useModalIndex, 33 | useModalStack, 34 | } 35 | -------------------------------------------------------------------------------- /src/Redirector.php: -------------------------------------------------------------------------------- 1 | generator->getRequest()->header(Modal::HEADER_BASE_URL)) { 26 | return $this->createRedirect($this->generator->to($baseUrl), $status, $headers); 27 | } 28 | 29 | return parent::back($status, $headers, $fallback); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /react/src/ModalRenderer.jsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react' 2 | import { useModalStack } from './ModalRoot' 3 | 4 | const ModalIndexContext = React.createContext(null) 5 | ModalIndexContext.displayName = 'ModalIndexContext' 6 | 7 | export const useModalIndex = () => { 8 | const context = React.useContext(ModalIndexContext) 9 | if (context === undefined) { 10 | throw new Error('useModalIndex must be used within a ModalIndexProvider') 11 | } 12 | return context 13 | } 14 | 15 | const ModalRenderer = ({ index }) => { 16 | const { stack } = useModalStack() 17 | 18 | const modalContext = useMemo(() => { 19 | return stack[index] 20 | }, [stack, index]) 21 | 22 | return ( 23 | modalContext?.component && ( 24 | 25 | modalContext.emit(...args)} 28 | /> 29 | 30 | ) 31 | ) 32 | } 33 | 34 | export default ModalRenderer 35 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Protone Media B.V. 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Testing/DuskModalMacros.php: -------------------------------------------------------------------------------- 1 | $this->waitFor('.im-dialog[data-inertiaui-modal-index="'.$index.'"] div[data-inertiaui-modal-entered="true"]', $seconds); 19 | } 20 | 21 | /** 22 | * Wait for the Modal to be removed. 23 | */ 24 | public function waitUntilMissingModal(): Closure 25 | { 26 | return fn (int $index = 0, ?int $seconds = null): Browser => $this->waitUntilMissing('.im-dialog[data-inertiaui-modal-index="'.$index.'"]', $seconds); 27 | } 28 | 29 | /** 30 | * Execute a Closure with a scoped Modal instance. 31 | */ 32 | public function withinModal(): Closure 33 | { 34 | return fn (Closure $callback, int $index = 0): Browser => $this->within('.im-dialog[data-inertiaui-modal-index="'.$index.'"]', $callback); 35 | } 36 | 37 | /** 38 | * Click the Modal close button. 39 | */ 40 | public function clickModalCloseButton(): Closure 41 | { 42 | return fn (int $index = 0): Browser => $this->click('.im-dialog[data-inertiaui-modal-index="'.$index.'"] .im-close-button'); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /vue/src/inertiauiModal.js: -------------------------------------------------------------------------------- 1 | import { getConfig, putConfig, resetConfig } from './config.js' 2 | import { useModalStack, initFromPageProps, renderApp } from './modalStack.js' 3 | import useModal from './useModal.js' 4 | import Deferred from './Deferred.vue' 5 | import HeadlessModal from './HeadlessModal.vue' 6 | import Modal from './Modal.vue' 7 | import ModalLink from './ModalLink.vue' 8 | import ModalRoot from './ModalRoot.vue' 9 | import WhenVisible from './WhenVisible.vue' 10 | 11 | function visitModal(url, options = {}) { 12 | return useModalStack() 13 | .visit( 14 | url, 15 | options.method ?? 'get', 16 | options.data ?? {}, 17 | options.headers ?? {}, 18 | options.config ?? {}, 19 | options.onClose, 20 | options.onAfterLeave, 21 | options.queryStringArrayFormat ?? 'brackets', 22 | options.navigate ?? getConfig('navigate'), 23 | options.onStart, 24 | options.onSuccess, 25 | options.onError, 26 | ) 27 | .then((modal) => { 28 | const listeners = options.listeners ?? {} 29 | 30 | Object.keys(listeners).forEach((event) => { 31 | // e.g. refreshKey -> refresh-key 32 | const eventName = event.replace(/([A-Z])/g, '-$1').toLowerCase() 33 | modal.on(eventName, listeners[event]) 34 | }) 35 | 36 | return modal 37 | }) 38 | } 39 | 40 | export { 41 | Deferred, 42 | HeadlessModal, 43 | Modal, 44 | ModalLink, 45 | ModalRoot, 46 | WhenVisible, 47 | getConfig, 48 | initFromPageProps, 49 | putConfig, 50 | renderApp, 51 | resetConfig, 52 | useModal, 53 | useModalStack, 54 | visitModal, 55 | } 56 | -------------------------------------------------------------------------------- /vue/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@inertiaui/modal-vue", 3 | "version": "1.0.0-beta-5", 4 | "author": "Pascal Baljet ", 5 | "private": false, 6 | "license": "MIT", 7 | "type": "module", 8 | "files": [ 9 | "dist", 10 | "src" 11 | ], 12 | "module": "./dist/inertiaui-modal.js", 13 | "main": "./dist/inertiaui-modal.umd.cjs", 14 | "exports": { 15 | ".": { 16 | "import": "./dist/inertiaui-modal.js", 17 | "require": "./dist/inertiaui-modal.umd.cjs" 18 | } 19 | }, 20 | "scripts": { 21 | "dev": "vite build --watch", 22 | "build": "vite build", 23 | "build-watch": "vite build --watch", 24 | "eslint": "eslint \"src/**/*.{js,vue}\" --fix", 25 | "prettier": "prettier -w src/", 26 | "test": "vitest" 27 | }, 28 | "devDependencies": { 29 | "@inertiajs/vue3": "^1.3.0||^2.1.11", 30 | "@vitejs/plugin-vue": "^5.2.0", 31 | "@vitest/coverage-v8": "^4.0.0", 32 | "@vitest/ui": "^4.0.0", 33 | "@vue/test-utils": "^2.4.6", 34 | "axios": "^1.6.0", 35 | "eslint": "^8.52.0", 36 | "eslint-config-prettier": "^9.0.0", 37 | "eslint-plugin-prettier": "^5.1.0", 38 | "eslint-plugin-tailwindcss": "^3.15.1", 39 | "eslint-plugin-unused-imports": "^3.1.0", 40 | "eslint-plugin-vue": "^9.23.0", 41 | "happy-dom": "^20.0.0", 42 | "prettier": "^3.2.4", 43 | "prettier-plugin-tailwindcss": "^0.5.12", 44 | "vite": "^6.1", 45 | "vite-plugin-eslint": "^1.8.1", 46 | "vitest": "^4.0.0", 47 | "vue": "^3.4.x" 48 | }, 49 | "peerDependencies": { 50 | "@inertiajs/vue3": "^1.3.0||^2.1.11", 51 | "axios": "^1.6.0", 52 | "vue": "^3.4.x" 53 | }, 54 | "dependencies": { 55 | "reka-ui": "^2.3.1" 56 | } 57 | } -------------------------------------------------------------------------------- /react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@inertiaui/modal-react", 3 | "author": "Pascal Baljet ", 4 | "version": "1.0.0-beta-5", 5 | "private": false, 6 | "license": "MIT", 7 | "type": "module", 8 | "files": [ 9 | "dist", 10 | "src" 11 | ], 12 | "module": "./dist/inertiaui-modal.js", 13 | "main": "./dist/inertiaui-modal.umd.cjs", 14 | "exports": { 15 | ".": { 16 | "import": "./dist/inertiaui-modal.js", 17 | "require": "./dist/inertiaui-modal.umd.cjs" 18 | } 19 | }, 20 | "scripts": { 21 | "dev": "NODE_ENV=development vite build --watch", 22 | "build": "vite build", 23 | "build-watch": "NODE_ENV=development vite build --watch", 24 | "eslint": "eslint \"src/**/*.{js,jsx}\" --fix", 25 | "prettier": "prettier -w src/" 26 | }, 27 | "devDependencies": { 28 | "@headlessui/react": "^2.1.0", 29 | "@heroicons/react": "^2.1.4", 30 | "@inertiajs/react": "^1.3.0||^2.1.11", 31 | "@vitejs/plugin-react": "^4.3.1", 32 | "axios": "^1.6.0", 33 | "clsx": "^2.1.1", 34 | "eslint-config-prettier": "^9.0.0", 35 | "eslint-plugin-prettier": "^5.1.0", 36 | "eslint-plugin-react-hooks": "^4.6.2", 37 | "eslint-plugin-react": "^7.34.3", 38 | "eslint-plugin-tailwindcss": "^3.15.1", 39 | "eslint-plugin-unused-imports": "^3.1.0", 40 | "eslint": "^8.57.0", 41 | "prettier-plugin-tailwindcss": "^0.5.12", 42 | "prettier": "^3.2.4", 43 | "react-dom": "^18.2.0||^19.0.0", 44 | "react": "^18.2.0||^19.0.0", 45 | "vite-plugin-eslint": "^1.8.1", 46 | "vite": "^6.1" 47 | }, 48 | "peerDependencies": { 49 | "@inertiajs/react": "^1.3.0||^2.1.11", 50 | "axios": "^1.6.0", 51 | "react": "^18.2.0||^19.0.0", 52 | "react-dom": "^18.2.0||^19.0.0" 53 | } 54 | } -------------------------------------------------------------------------------- /vue/src/config.js: -------------------------------------------------------------------------------- 1 | const defaultConfig = { 2 | type: 'modal', 3 | navigate: false, 4 | modal: { 5 | closeButton: true, 6 | closeExplicitly: false, 7 | maxWidth: '2xl', 8 | paddingClasses: 'p-4 sm:p-6', 9 | panelClasses: 'bg-white rounded', 10 | position: 'center', 11 | }, 12 | slideover: { 13 | closeButton: true, 14 | closeExplicitly: false, 15 | maxWidth: 'md', 16 | paddingClasses: 'p-4 sm:p-6', 17 | panelClasses: 'bg-white min-h-screen', 18 | position: 'right', 19 | }, 20 | } 21 | 22 | class Config { 23 | constructor() { 24 | this.config = {} 25 | this.reset() 26 | } 27 | 28 | reset() { 29 | this.config = JSON.parse(JSON.stringify(defaultConfig)) 30 | } 31 | 32 | put(key, value) { 33 | if (typeof key === 'object') { 34 | this.config = { 35 | type: key.type ?? defaultConfig.type, 36 | navigate: key.navigate ?? defaultConfig.navigate, 37 | modal: { ...defaultConfig.modal, ...(key.modal ?? {}) }, 38 | slideover: { ...defaultConfig.slideover, ...(key.slideover ?? {}) }, 39 | } 40 | return 41 | } 42 | const keys = key.split('.') 43 | let current = this.config 44 | for (let i = 0; i < keys.length - 1; i++) { 45 | current = current[keys[i]] = current[keys[i]] || {} 46 | } 47 | current[keys[keys.length - 1]] = value 48 | } 49 | 50 | get(key) { 51 | if (typeof key === 'undefined') { 52 | return this.config 53 | } 54 | const keys = key.split('.') 55 | let current = this.config 56 | for (const k of keys) { 57 | if (current[k] === undefined) { 58 | return null 59 | } 60 | current = current[k] 61 | } 62 | return current 63 | } 64 | } 65 | 66 | const configInstance = new Config() 67 | 68 | export const resetConfig = () => configInstance.reset() 69 | export const putConfig = (key, value) => configInstance.put(key, value) 70 | export const getConfig = (key) => configInstance.get(key) 71 | export const getConfigByType = (isSlideover, key) => configInstance.get(isSlideover ? `slideover.${key}` : `modal.${key}`) 72 | -------------------------------------------------------------------------------- /vue/src/WhenVisible.vue: -------------------------------------------------------------------------------- 1 | 80 | 81 | 93 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "inertiaui/modal", 3 | "description": "Inertia Modal", 4 | "homepage": "https://github.com/inertiaui/modal", 5 | "license": "MIT", 6 | "require": { 7 | "php": "^8.2", 8 | "illuminate/contracts": "^10.48||^11.11||^12.0", 9 | "inertiajs/inertia-laravel": "^1.3|^2.0" 10 | }, 11 | "require-dev": { 12 | "larastan/larastan": "^2.9", 13 | "laravel/dusk": "^8.3", 14 | "laravel/pint": "^1.14", 15 | "orchestra/testbench": "^8.23||^9.1||^10.0", 16 | "rector/rector": "^1.0.3" 17 | }, 18 | "conflict": { 19 | "friendsofphp/php-cs-fixer": "<3.74.0" 20 | }, 21 | "autoload": { 22 | "psr-4": { 23 | "InertiaUI\\Modal\\": "src/" 24 | }, 25 | "files": [ 26 | "src/helpers.php" 27 | ] 28 | }, 29 | "scripts": { 30 | "analyse": "vendor/bin/phpstan analyse --memory-limit=512M", 31 | "format": "vendor/bin/pint", 32 | "refactor": "vendor/bin/rector", 33 | "eslint-react": "cd react && npm run eslint", 34 | "eslint-vue": "cd vue && npm run eslint", 35 | "build-react": "cd react && npm run build", 36 | "build-vue": "cd vue && npm run build", 37 | "all": [ 38 | "@analyse", 39 | "@refactor", 40 | "@format", 41 | "@eslint-react", 42 | "@eslint-vue" 43 | ], 44 | "build": [ 45 | "@all", 46 | "@build-react", 47 | "@build-vue" 48 | ], 49 | "version": [ 50 | "cd react && npm version ${VERSION}", 51 | "@update-react", 52 | "@build-react", 53 | "cd vue && npm version ${VERSION}", 54 | "@update-vue", 55 | "@build-vue" 56 | ], 57 | "update-react": "cd react && npm upgrade", 58 | "update-vue": "cd vue && npm upgrade", 59 | "update-demo-app": "cd demo-app && composer update && npm upgrade && php artisan dusk:chrome-driver --detect", 60 | "update-all": [ 61 | "composer update", 62 | "@update-react", 63 | "@update-vue", 64 | "@update-demo-app" 65 | ] 66 | }, 67 | "config": { 68 | "sort-packages": true, 69 | "allow-plugins": { 70 | "phpstan/extension-installer": true, 71 | "pestphp/pest-plugin": false 72 | } 73 | }, 74 | "extra": { 75 | "laravel": { 76 | "providers": [ 77 | "InertiaUI\\Modal\\ModalServiceProvider" 78 | ] 79 | } 80 | }, 81 | "minimum-stability": "dev", 82 | "prefer-stable": true 83 | } -------------------------------------------------------------------------------- /vue/src/ModalRoot.vue: -------------------------------------------------------------------------------- 1 | 69 | 70 | 78 | -------------------------------------------------------------------------------- /react/src/WhenVisible.jsx: -------------------------------------------------------------------------------- 1 | // See: https://github.com/inertiajs/inertia/blob/48bcd21fb7daf467d0df1bfde2408f161f94a579/packages/react/src/WhenVisible.ts 2 | import { createElement, useCallback, useEffect, useRef, useState } from 'react' 3 | import useModal from './useModal' 4 | 5 | const WhenVisible = ({ children, data, params, buffer, as, always, fallback }) => { 6 | always = always ?? false 7 | as = as ?? 'div' 8 | fallback = fallback ?? null 9 | 10 | const [loaded, setLoaded] = useState(false) 11 | const hasFetched = useRef(false) 12 | const fetching = useRef(false) 13 | const ref = useRef(null) 14 | 15 | const modal = useModal() 16 | 17 | const getReloadParams = useCallback(() => { 18 | if (data) { 19 | return { 20 | only: Array.isArray(data) ? data : [data], 21 | } 22 | } 23 | 24 | if (!params) { 25 | throw new Error('You must provide either a `data` or `params` prop.') 26 | } 27 | 28 | return params 29 | }, [params, data]) 30 | 31 | useEffect(() => { 32 | if (!ref.current) { 33 | return 34 | } 35 | 36 | const observer = new IntersectionObserver( 37 | (entries) => { 38 | if (!entries[0].isIntersecting) { 39 | return 40 | } 41 | 42 | if (!always && hasFetched.current) { 43 | observer.disconnect() 44 | } 45 | 46 | if (fetching.current) { 47 | return 48 | } 49 | 50 | hasFetched.current = true 51 | fetching.current = true 52 | 53 | const reloadParams = getReloadParams() 54 | 55 | modal.reload({ 56 | ...reloadParams, 57 | onStart: (e) => { 58 | fetching.current = true 59 | reloadParams.onStart?.(e) 60 | }, 61 | onFinish: (e) => { 62 | setLoaded(true) 63 | fetching.current = false 64 | reloadParams.onFinish?.(e) 65 | 66 | if (!always) { 67 | observer.disconnect() 68 | } 69 | }, 70 | }) 71 | }, 72 | { 73 | rootMargin: `${buffer || 0}px`, 74 | }, 75 | ) 76 | 77 | observer.observe(ref.current) 78 | 79 | return () => { 80 | observer.disconnect() 81 | } 82 | }, [ref, getReloadParams, buffer]) 83 | 84 | if (always || !loaded) { 85 | return createElement( 86 | as, 87 | { 88 | props: null, 89 | ref, 90 | }, 91 | loaded ? children : fallback, 92 | ) 93 | } 94 | 95 | return loaded ? children : null 96 | } 97 | 98 | WhenVisible.displayName = 'InertiaWhenVisible' 99 | 100 | export default WhenVisible 101 | -------------------------------------------------------------------------------- /react/src/ModalContent.jsx: -------------------------------------------------------------------------------- 1 | import { TransitionChild, DialogPanel } from '@headlessui/react' 2 | import CloseButton from './CloseButton' 3 | import clsx from 'clsx' 4 | import { useState } from 'react' 5 | 6 | const ModalContent = ({ modalContext, config, children }) => { 7 | const [entered, setEntered] = useState(false) 8 | 9 | return ( 10 |
11 |
18 | setEntered(true)} 25 | afterLeave={modalContext.afterLeave} 26 | className={clsx('im-modal-wrapper w-full transition duration-300 ease-in-out', modalContext.onTopOfStack ? '' : 'blur-sm', { 27 | 'sm:max-w-sm': config.maxWidth === 'sm', 28 | 'sm:max-w-md': config.maxWidth === 'md', 29 | 'sm:max-w-md md:max-w-lg': config.maxWidth === 'lg', 30 | 'sm:max-w-md md:max-w-xl': config.maxWidth === 'xl', 31 | 'sm:max-w-md md:max-w-xl lg:max-w-2xl': config.maxWidth === '2xl', 32 | 'sm:max-w-md md:max-w-xl lg:max-w-3xl': config.maxWidth === '3xl', 33 | 'sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-4xl': config.maxWidth === '4xl', 34 | 'sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-5xl': config.maxWidth === '5xl', 35 | 'sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-5xl 2xl:max-w-6xl': config.maxWidth === '6xl', 36 | 'sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-5xl 2xl:max-w-7xl': config.maxWidth === '7xl', 37 | })} 38 | > 39 | 43 | {config.closeButton && ( 44 |
45 | 46 |
47 | )} 48 | {typeof children === 'function' ? children({ modalContext, config }) : children} 49 |
50 |
51 |
52 |
53 | ) 54 | } 55 | 56 | export default ModalContent 57 | -------------------------------------------------------------------------------- /src/ModalVisit.php: -------------------------------------------------------------------------------- 1 | method = $method; 44 | 45 | return $this; 46 | } 47 | 48 | /** 49 | * Configures whether the Base Route / URL feature should be used. 50 | */ 51 | public function navigate(?bool $navigate = true): self 52 | { 53 | $this->navigate = $navigate; 54 | 55 | return $this; 56 | } 57 | 58 | /** 59 | * Sets the data to be sent with the modal visit. 60 | */ 61 | public function data(?array $data): self 62 | { 63 | $this->data = blank($data) ? null : $data; 64 | 65 | return $this; 66 | } 67 | 68 | /** 69 | * Sets the headers to be sent with the modal visit. 70 | */ 71 | public function headers(?array $headers): self 72 | { 73 | $this->headers = blank($headers) ? null : $headers; 74 | 75 | return $this; 76 | } 77 | 78 | /** 79 | * Sets the configuration for the modal visit. 80 | */ 81 | public function config(ModalConfig|callable|null $config): self 82 | { 83 | if (is_callable($config)) { 84 | $config = tap(ModalConfig::new(), $config); 85 | } 86 | 87 | $this->config = $config; 88 | 89 | return $this; 90 | } 91 | 92 | /** 93 | * Sets the query string array format for the modal visit. 94 | */ 95 | public function queryStringArrayFormat(?QueryStringArrayFormat $queryStringArrayFormat): self 96 | { 97 | $this->queryStringArrayFormat = $queryStringArrayFormat; 98 | 99 | return $this; 100 | } 101 | 102 | /** 103 | * Converts the modal visit to an array. 104 | */ 105 | public function toArray(): array 106 | { 107 | return [ 108 | 'method' => $this->method, 109 | 'navigate' => $this->navigate, 110 | 'data' => $this->data, 111 | 'headers' => $this->headers, 112 | 'config' => $this->config?->toArray(), 113 | 'queryStringArrayFormat' => $this->queryStringArrayFormat?->value, 114 | ]; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /react/src/SlideoverContent.jsx: -------------------------------------------------------------------------------- 1 | import { TransitionChild, DialogPanel } from '@headlessui/react' 2 | import CloseButton from './CloseButton' 3 | import clsx from 'clsx' 4 | import { useState } from 'react' 5 | 6 | const SlideoverContent = ({ modalContext, config, children }) => { 7 | const [entered, setEntered] = useState(false) 8 | 9 | return ( 10 |
11 |
17 | setEntered(true)} 24 | afterLeave={modalContext.afterLeave} 25 | className={clsx('im-slideover-wrapper w-full transition duration-300 ease-in-out', modalContext.onTopOfStack ? '' : 'blur-sm', { 26 | 'sm:max-w-sm': config.maxWidth === 'sm', 27 | 'sm:max-w-md': config.maxWidth === 'md', 28 | 'sm:max-w-md md:max-w-lg': config.maxWidth === 'lg', 29 | 'sm:max-w-md md:max-w-xl': config.maxWidth === 'xl', 30 | 'sm:max-w-md md:max-w-xl lg:max-w-2xl': config.maxWidth === '2xl', 31 | 'sm:max-w-md md:max-w-xl lg:max-w-3xl': config.maxWidth === '3xl', 32 | 'sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-4xl': config.maxWidth === '4xl', 33 | 'sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-5xl': config.maxWidth === '5xl', 34 | 'sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-5xl 2xl:max-w-6xl': config.maxWidth === '6xl', 35 | 'sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-5xl 2xl:max-w-7xl': config.maxWidth === '7xl', 36 | })} 37 | > 38 | 42 | {config.closeButton && ( 43 |
44 | 45 |
46 | )} 47 | {typeof children === 'function' ? children({ modalContext, config }) : children} 48 |
49 |
50 |
51 |
52 | ) 53 | } 54 | 55 | export default SlideoverContent 56 | -------------------------------------------------------------------------------- /vue/src/ModalContent.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 80 | -------------------------------------------------------------------------------- /vue/src/SlideoverContent.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 79 | -------------------------------------------------------------------------------- /src/DispatchBaseUrlRequest.php: -------------------------------------------------------------------------------- 1 | query->all(), 31 | $originalRequest->cookies->all(), 32 | $originalRequest->files->all(), 33 | $originalRequest->server->all(), 34 | ); 35 | 36 | $requestForBaseUrl->headers->replace($originalRequest->headers->all()); 37 | $requestForBaseUrl->setRequestLocale($originalRequest->getLocale()); 38 | $requestForBaseUrl->setDefaultRequestLocale($originalRequest->getDefaultLocale()); 39 | 40 | $route = $this->router->getRoutes()->match($requestForBaseUrl); 41 | $requestForBaseUrl->setRouteResolver(fn () => $route); 42 | 43 | // No need to call setLaravelSession() as it's done by the StartSession middleware 44 | // No need to call setUserResolver() as it's done by AuthServiceProvider::registerRequestRebindHandler() 45 | 46 | // Dispatch the request without encrypting cookies because that has 47 | // already happens in the original request. We don't want to 48 | // double-encrypt them, as that would nullify the cookies. 49 | $this->bindRequest($requestForBaseUrl); 50 | 51 | $response = (new Pipeline(app())) 52 | ->send($requestForBaseUrl) 53 | ->through($this->gatherMiddleware($route)) 54 | ->then(function ($requestForBaseUrl) use ($route) { 55 | $this->bindRequest($requestForBaseUrl); 56 | 57 | $response = $route->run(); 58 | 59 | if ($response instanceof Responsable) { 60 | return $response->toResponse($requestForBaseUrl); 61 | } 62 | 63 | return $response; 64 | }); 65 | 66 | return tap($response, fn () => $this->bindRequest($originalRequest)); 67 | } 68 | 69 | /** 70 | * Bind the given request to the container and set it as the current request for the router. 71 | */ 72 | private function bindRequest(Request $request): void 73 | { 74 | Facade::clearResolvedInstance('request'); 75 | 76 | app()->instance('request', $request); 77 | 78 | // @phpstan-ignore-next-line 79 | $this->router->setCurrentRequest($request); 80 | } 81 | 82 | /** 83 | * Gather the middleware for the given route and exclude the configured middleware. 84 | */ 85 | private function gatherMiddleware(Route $route): mixed 86 | { 87 | $excludedMiddleware = Modal::getMiddlewareToExcludeOnBaseUrl(); 88 | 89 | return collect($this->router->gatherRouteMiddleware($route)) 90 | ->reject(function ($middleware) use ($excludedMiddleware): bool { 91 | foreach ($excludedMiddleware as $excludeMiddleware) { 92 | if ($middleware === $excludeMiddleware || is_subclass_of($middleware, $excludeMiddleware)) { 93 | return true; 94 | } 95 | } 96 | 97 | return false; 98 | }) 99 | ->values() 100 | ->all(); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /react/src/ModalLink.jsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useState, useEffect, useMemo } from 'react' 2 | import { useModalStack, modalPropNames } from './ModalRoot' 3 | import { only, rejectNullValues, isStandardDomEvent } from './helpers' 4 | import { getConfig } from './config' 5 | 6 | const ModalLink = ({ 7 | href, 8 | method = 'get', 9 | data = {}, 10 | as: Component = 'a', 11 | headers = {}, 12 | queryStringArrayFormat = 'brackets', 13 | onAfterLeave = null, 14 | onBlur = null, 15 | onClose = null, 16 | onError = null, 17 | onFocus = null, 18 | onStart = null, 19 | onSuccess = null, 20 | navigate = null, 21 | children, 22 | ...props 23 | }) => { 24 | const [loading, setLoading] = useState(false) 25 | const [modalContext, setModalContext] = useState(null) 26 | const { stack, visit } = useModalStack() 27 | 28 | const shouldNavigate = useMemo(() => { 29 | return navigate ?? getConfig('navigate') 30 | }, [navigate]) 31 | 32 | // Separate standard props from custom event handlers 33 | const standardProps = {} 34 | const customEvents = {} 35 | 36 | Object.keys(props).forEach((key) => { 37 | if (modalPropNames.includes(key)) { 38 | return 39 | } 40 | 41 | if (key.startsWith('on') && typeof props[key] === 'function') { 42 | if (isStandardDomEvent(key)) { 43 | standardProps[key] = props[key] 44 | } else { 45 | customEvents[key] = props[key] 46 | } 47 | } else { 48 | standardProps[key] = props[key] 49 | } 50 | }) 51 | 52 | const [isBlurred, setIsBlurred] = useState(false) 53 | 54 | useEffect(() => { 55 | if (!modalContext) { 56 | return 57 | } 58 | 59 | if (modalContext.onTopOfStack && isBlurred) { 60 | onFocus?.() 61 | } else if (!modalContext.onTopOfStack && !isBlurred) { 62 | onBlur?.() 63 | } 64 | 65 | setIsBlurred(!modalContext.onTopOfStack) 66 | }, [stack]) 67 | 68 | const onCloseCallback = useCallback(() => { 69 | onClose?.() 70 | }, [onClose]) 71 | 72 | const onAfterLeaveCallback = useCallback(() => { 73 | setModalContext(null) 74 | onAfterLeave?.() 75 | }, [onAfterLeave]) 76 | 77 | const handle = useCallback( 78 | (e) => { 79 | e?.preventDefault() 80 | if (loading) return 81 | 82 | if (!href.startsWith('#')) { 83 | setLoading(true) 84 | onStart?.() 85 | } 86 | 87 | visit( 88 | href, 89 | method, 90 | data, 91 | headers, 92 | rejectNullValues(only(props, modalPropNames)), 93 | () => onCloseCallback(stack.length), 94 | onAfterLeaveCallback, 95 | queryStringArrayFormat, 96 | shouldNavigate, 97 | ) 98 | .then((newModalContext) => { 99 | setModalContext(newModalContext) 100 | newModalContext.registerEventListenersFromProps(customEvents) 101 | onSuccess?.() 102 | }) 103 | .catch((error) => { 104 | console.error(error) 105 | onError?.(error) 106 | }) 107 | .finally(() => setLoading(false)) 108 | }, 109 | [href, method, data, headers, queryStringArrayFormat, props, onCloseCallback, onAfterLeaveCallback], 110 | ) 111 | 112 | return ( 113 | 118 | {typeof children === 'function' ? children({ loading }) : children} 119 | 120 | ) 121 | } 122 | 123 | export default ModalLink 124 | -------------------------------------------------------------------------------- /vue/src/ModalLink.vue: -------------------------------------------------------------------------------- 1 | 159 | 160 | 170 | -------------------------------------------------------------------------------- /src/ModalServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->runningInConsole()) { 23 | $this->publishes([ 24 | __DIR__.'/../config/inertiaui-modal.php' => config_path('inertiaui-modal.php'), 25 | ], 'config'); 26 | } 27 | 28 | // Add a 'modal' macro to ResponseFactory for convenient modal creation 29 | // like Inertia::modal('Component', ['prop' => 'value']) 30 | ResponseFactory::macro('modal', fn ($component, $props = []): \InertiaUI\Modal\Modal => new Modal($component, $props)); 31 | 32 | // Register a callback to reset the BladeRouteGenerator state before 33 | // rendering the Base Route/URL with the Modal component. 34 | Modal::beforeBaseRerender(function (): void { 35 | if (class_exists(BladeRouteGenerator::class)) { 36 | BladeRouteGenerator::$generated = false; 37 | } 38 | }); 39 | 40 | // Prevent double encryption of cookies in subrequests 41 | Modal::excludeMiddlewareOnBaseUrl(EncryptCookies::class); 42 | 43 | Router::macro('setCurrentRequest', function ($request): void { 44 | // @phpstan-ignore-next-line 45 | $this->currentRequest = $request; 46 | }); 47 | 48 | // Add a 'toArray' macro to Response for consistent serialization to so that 49 | // any response can be serialized to an array. This is used in the Modal 50 | // class to pass the modal data as a prop to the base URL. 51 | Response::macro('toArray', function (): array { 52 | $request = app('request'); 53 | 54 | if (Support::isInertiaV2()) { 55 | $props = $this->resolveProperties($request, $this->props); 56 | } else { 57 | $props = $this->resolvePartialProps($request, $this->props); 58 | $props = $this->resolveAlwaysProps($props); 59 | $props = $this->evaluateProps($props, $request); 60 | } 61 | 62 | return [ 63 | 'component' => $this->component, 64 | 'props' => $props, 65 | 'version' => $this->version, 66 | 'url' => Str::start(Str::after($request->fullUrl(), $request->getSchemeAndHttpHost()), '/'), 67 | 'meta' => Support::isInertiaV2() ? [ 68 | ...$this->resolveMergeProps($request), 69 | ...$this->resolveDeferredProps($request), 70 | ...$this->resolveCacheDirections($request), 71 | ] : [], 72 | ]; 73 | }); 74 | 75 | $this->app->singleton('inertiaui_modal_redirector', function ($app): \InertiaUI\Modal\Redirector { 76 | $redirector = new Redirector($app['url']); 77 | 78 | // If the session is set on the application instance, we'll inject it into 79 | // the redirector instance. This allows the redirect responses to allow 80 | // for the quite convenient "with" methods that flash to the session. 81 | if (isset($app['session.store'])) { 82 | $redirector->setSession($app['session.store']); 83 | } 84 | 85 | return $redirector; 86 | }); 87 | 88 | if (config('inertiaui-modal.bind_extended_redirector', true)) { 89 | // Replace Laravel's default Redirector with our custom one 90 | // This allows us to intercept and modify redirect behavior for modals, 91 | // ensuring that modal-specific redirects (like closing a modal) work correctly. 92 | $this->app->alias('inertiaui_modal_redirector', 'redirect'); 93 | } 94 | } 95 | 96 | /** 97 | * Register the Inertia Modal package. 98 | */ 99 | public function register(): void 100 | { 101 | $this->mergeConfigFrom(__DIR__.'/../config/inertiaui-modal.php', 'inertiaui-modal'); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /vue/tests/config.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach } from 'vitest' 2 | import { resetConfig, putConfig, getConfig } from '../src/config' 3 | 4 | describe('Config Module', () => { 5 | // Reset the config before each test 6 | beforeEach(() => { 7 | resetConfig() 8 | }) 9 | 10 | describe('getConfig', () => { 11 | it('should return the correct value for a top-level key', () => { 12 | expect(getConfig('type')).toBe('modal') 13 | }) 14 | 15 | it('should return the correct value for a nested key', () => { 16 | expect(getConfig('modal.closeButton')).toBe(true) 17 | expect(getConfig('slideover.maxWidth')).toBe('md') 18 | }) 19 | 20 | it('should return null for non-existent keys', () => { 21 | expect(getConfig('nonexistent')).toBeNull() 22 | expect(getConfig('modal.nonexistent')).toBeNull() 23 | expect(getConfig('modal.closeButton.nonexistent')).toBeNull() 24 | }) 25 | 26 | it('should handle deeply nested keys', () => { 27 | putConfig('a.b.c.d.e', 'deep value') 28 | expect(getConfig('a.b.c.d.e')).toBe('deep value') 29 | }) 30 | }) 31 | 32 | describe('putConfig', () => { 33 | it('should set a value for a top-level key', () => { 34 | putConfig('newKey', 'newValue') 35 | expect(getConfig('newKey')).toBe('newValue') 36 | }) 37 | 38 | it('should set a value for a nested key', () => { 39 | putConfig('modal.newKey', 'newValue') 40 | expect(getConfig('modal.newKey')).toBe('newValue') 41 | }) 42 | 43 | it("should create intermediate objects if they don't exist", () => { 44 | putConfig('a.b.c', 'nestedValue') 45 | expect(getConfig('a.b.c')).toBe('nestedValue') 46 | }) 47 | 48 | it('should override existing values', () => { 49 | putConfig('modal.closeButton', false) 50 | expect(getConfig('modal.closeButton')).toBe(false) 51 | }) 52 | 53 | it('should handle deeply nested keys', () => { 54 | putConfig('x.y.z.1.2.3', 'deep nested value') 55 | expect(getConfig('x.y.z.1.2.3')).toBe('deep nested value') 56 | }) 57 | 58 | it('should handle passing a whole config object', () => { 59 | const newConfig = { 60 | type: 'slideover', 61 | modal: { 62 | closeButton: false, 63 | maxWidth: 'lg', 64 | }, 65 | slideover: { 66 | position: 'left', 67 | }, 68 | } 69 | putConfig(newConfig) 70 | 71 | expect(getConfig('type')).toBe('slideover') 72 | expect(getConfig('modal.closeButton')).toBe(false) 73 | expect(getConfig('modal.maxWidth')).toBe('lg') 74 | expect(getConfig('slideover.position')).toBe('left') 75 | 76 | // Check that untouched properties remain unchanged 77 | expect(getConfig('modal.closeExplicitly')).toBe(false) 78 | expect(getConfig('slideover.maxWidth')).toBe('md') 79 | }) 80 | }) 81 | 82 | describe('Integration of putConfig and getConfig', () => { 83 | it('should be able to set and then get a value', () => { 84 | putConfig('test.key', 'test value') 85 | expect(getConfig('test.key')).toBe('test value') 86 | }) 87 | 88 | it('should handle overwriting of object properties', () => { 89 | putConfig('modal.newObject', { a: 1, b: 2 }) 90 | expect(getConfig('modal.newObject')).toEqual({ a: 1, b: 2 }) 91 | putConfig('modal.newObject.a', 3) 92 | expect(getConfig('modal.newObject')).toEqual({ a: 3, b: 2 }) 93 | }) 94 | }) 95 | 96 | describe('resetConfig', () => { 97 | it('should reset the config to its default state', () => { 98 | putConfig('type', 'changed') 99 | putConfig('newKey', 'newValue') 100 | putConfig('modal.closeButton', false) 101 | 102 | resetConfig() 103 | 104 | expect(getConfig('type')).toBe('modal') 105 | expect(getConfig('newKey')).toBeNull() 106 | expect(getConfig('modal.closeButton')).toBe(true) 107 | }) 108 | 109 | it('should not affect subsequent tests after reset', () => { 110 | expect(getConfig('type')).toBe('modal') 111 | expect(getConfig('modal.closeButton')).toBe(true) 112 | }) 113 | }) 114 | }) 115 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Inertia Modal 2 | 3 | [![Tests](https://github.com/inertiaui/modal/actions/workflows/tests.yml/badge.svg)](https://github.com/inertiaui/modal/actions/workflows/tests.yml) 4 | ![GitHub Sponsors](https://img.shields.io/github/sponsors/pascalbaljet) 5 | 6 | Inertia Modal is part of [Inertia UI](https://inertiaui.com), a suite of packages designed for Laravel, Inertia.js, and Tailwind CSS. With Inertia Modal, you can easily **open any route in a Modal or Slideover** without having to change anything about your existing routes or controllers. 7 | 8 | You may find the documentation at [https://inertiaui.com/inertia-modal/docs](https://inertiaui.com/inertia-modal/docs) and a video demo at [https://www.youtube.com/watch?v=KAKOosmWV14](https://www.youtube.com/watch?v=KAKOosmWV14). 9 | 10 | ## Sponsor Us 11 | 12 | [](https://inertiaui.com/inertia-table?utm_source=github&utm_campaign=inertia-modal) 13 | 14 | ❤️ We proudly support the community by developing Laravel packages and giving them away for free. If this package saves you time or if you're relying on it professionally, please consider [sponsoring the maintenance and development](https://github.com/sponsors/pascalbaljet) and check out our latest premium package: [Inertia Table](https://inertiaui.com/inertia-table?utm_source=github&utm_campaign=inertia-modal). Keeping track of issues and pull requests takes time, but we're happy to help! 15 | 16 | ## Features 17 | 18 | - Supports React and Vue 19 | - Zero backend configuration 20 | - Super simple frontend API 21 | - Support for Base Route / URL 22 | - Modal and slideover support 23 | - Headless support 24 | - Nested/stacked modals support 25 | - Reusable modals 26 | - Multiple sizes and positions 27 | - Reload props in modals 28 | - Easy communication between nested/stacked modals 29 | - Highly configurable 30 | 31 | # Requirements 32 | 33 | Inertia Modal has the following requirements: 34 | 35 | - Tailwind CSS 3.4+ 36 | - React 18+ or Vue 3.4+ 37 | 38 | The package is designed and tested to work with Laravel and Inertia.js v1/v2. It may work with other backend frameworks and Inertia.js versions, but there is no guarantee or support for such configurations. 39 | 40 | The Base Route / URL feature is supported in both Laravel 10 and 11. 41 | 42 | ## Changelog 43 | 44 | Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. 45 | 46 | ## Contributing 47 | 48 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 49 | 50 | ## Other Laravel packages 51 | 52 | - [`Inertia Table`](https://inertiaui.com/inertia-table?utm_source=github&utm_campaign=laravel-splade): The Ultimate Table for Inertia.js with built-in Query Builder. 53 | - [`Laravel Blade On Demand`](https://github.com/protonemedia/laravel-blade-on-demand): Laravel package to compile Blade templates in memory. 54 | - [`Laravel Cross Eloquent Search`](https://github.com/protonemedia/laravel-cross-eloquent-search): Laravel package to search through multiple Eloquent models. 55 | - [`Laravel Eloquent Scope as Select`](https://github.com/protonemedia/laravel-eloquent-scope-as-select): Stop duplicating your Eloquent query scopes and constraints in PHP. This package lets you re-use your query scopes and constraints by adding them as a subquery. 56 | - [`Laravel Eloquent Where Not`](https://github.com/protonemedia/laravel-eloquent-where-not): This Laravel package allows you to flip/invert an Eloquent scope, or really any query constraint. 57 | - [`Laravel MinIO Testing Tools`](https://github.com/protonemedia/laravel-minio-testing-tools): This package provides a trait to run your tests against a MinIO S3 server. 58 | - [`Laravel Mixins`](https://github.com/protonemedia/laravel-mixins): A collection of Laravel goodies. 59 | - [`Laravel Paddle`](https://github.com/protonemedia/laravel-paddle): Paddle.com API integration for Laravel with support for webhooks/events. 60 | - [`Laravel Verify New Email`](https://github.com/protonemedia/laravel-verify-new-email): This package adds support for verifying new email addresses: when a user updates its email address, it won't replace the old one until the new one is verified. 61 | - [`Laravel XSS Protection Middleware`](https://github.com/protonemedia/laravel-xss-protection): Laravel Middleware to protect your app against Cross-site scripting (XSS). It sanitizes request input by utilising the Laravel Security package, and it can sanatize Blade echo statements as well. 62 | 63 | ## Security Vulnerabilities 64 | 65 | If you discover a security vulnerability within Inertia UI, please send an e-mail to Pascal Baljet via [pascal@protone.media](mailto:pascal@protone.media). All security vulnerabilities will be promptly addressed. 66 | 67 | ## Credits 68 | 69 | - [Pascal Baljet](https://github.com/protonemedia) 70 | -------------------------------------------------------------------------------- /vue/src/helpers.js: -------------------------------------------------------------------------------- 1 | let generateIdUsingCallback = null 2 | 3 | function generateIdUsing(callback) { 4 | generateIdUsingCallback = callback 5 | } 6 | 7 | function sameUrlPath(url1, url2) { 8 | const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost' 9 | url1 = typeof url1 === 'string' ? new URL(url1, origin) : url1 10 | url2 = typeof url2 === 'string' ? new URL(url2, origin) : url2 11 | 12 | return `${url1.origin}${url1.pathname}` === `${url2.origin}${url2.pathname}` 13 | } 14 | 15 | function generateId(prefix = 'inertiaui_modal_') { 16 | if (generateIdUsingCallback) { 17 | return generateIdUsingCallback() 18 | } 19 | 20 | if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { 21 | return `${prefix}${crypto.randomUUID()}` 22 | } 23 | 24 | // Fallback for environments where crypto.randomUUID is not available 25 | return `${prefix}${Date.now().toString(36)}_${Math.random().toString(36).substr(2, 9)}` 26 | } 27 | 28 | function strToLowercase(key) { 29 | return typeof key === 'string' ? key.toLowerCase() : key 30 | } 31 | 32 | function except(target, keys, ignoreCase = false) { 33 | if (ignoreCase) { 34 | keys = keys.map(strToLowercase) 35 | } 36 | 37 | if (Array.isArray(target)) { 38 | return target.filter((key) => !keys.includes(ignoreCase ? strToLowercase(key) : key)) 39 | } 40 | 41 | return Object.keys(target).reduce((acc, key) => { 42 | if (!keys.includes(ignoreCase ? strToLowercase(key) : key)) { 43 | acc[key] = target[key] // copy the key-value pair 44 | } 45 | 46 | return acc 47 | }, {}) 48 | } 49 | 50 | function only(target, keys, ignoreCase = false) { 51 | if (ignoreCase) { 52 | keys = keys.map(strToLowercase) 53 | } 54 | 55 | if (Array.isArray(target)) { 56 | return target.filter((key) => keys.includes(ignoreCase ? strToLowercase(key) : key)) 57 | } 58 | 59 | return Object.keys(target).reduce((acc, key) => { 60 | if (keys.includes(ignoreCase ? strToLowercase(key) : key)) { 61 | acc[key] = target[key] // copy the key-value pair 62 | } 63 | 64 | return acc 65 | }, {}) 66 | } 67 | 68 | function rejectNullValues(target) { 69 | if (Array.isArray(target)) { 70 | return target.filter((item) => item !== null) 71 | } 72 | 73 | return Object.keys(target).reduce((acc, key) => { 74 | if (key in target && target[key] !== null) { 75 | acc[key] = target[key] 76 | } 77 | return acc 78 | }, {}) 79 | } 80 | 81 | function kebabCase(string) { 82 | if (!string) return '' 83 | 84 | // Replace all underscores with hyphens 85 | string = string.replace(/_/g, '-') 86 | 87 | // Replace all multiple consecutive hyphens with a single hyphen 88 | string = string.replace(/-+/g, '-') 89 | 90 | // Check if string is already all lowercase 91 | if (!/[A-Z]/.test(string)) { 92 | return string 93 | } 94 | 95 | // Remove all spaces and convert to word case 96 | string = string 97 | .replace(/\s+/g, '') 98 | .replace(/_/g, '') 99 | .replace(/(?:^|\s|-)+([A-Za-z])/g, (m, p1) => p1.toUpperCase()) 100 | 101 | // Add delimiter before uppercase letters 102 | string = string.replace(/(.)(?=[A-Z])/g, '$1-') 103 | 104 | // Convert to lowercase 105 | return string.toLowerCase() 106 | } 107 | 108 | function isStandardDomEvent(eventName) { 109 | if (typeof window !== 'undefined') { 110 | return eventName.toLowerCase() in window 111 | } 112 | 113 | if (typeof document !== 'undefined') { 114 | const testElement = document.createElement('div') 115 | return eventName.toLowerCase() in testElement 116 | } 117 | 118 | const lowerEventName = eventName.toLowerCase() 119 | const standardPatterns = [ 120 | /^on(click|dblclick|mousedown|mouseup|mouseover|mouseout|mousemove|mouseenter|mouseleave)$/, 121 | /^on(keydown|keyup|keypress)$/, 122 | /^on(focus|blur|change|input|submit|reset)$/, 123 | /^on(load|unload|error|resize|scroll)$/, 124 | /^on(touchstart|touchend|touchmove|touchcancel)$/, 125 | /^on(pointerdown|pointerup|pointermove|pointerenter|pointerleave|pointercancel)$/, 126 | /^on(drag|dragstart|dragend|dragenter|dragleave|dragover|drop)$/, 127 | /^on(animationstart|animationend|animationiteration)$/, 128 | /^on(transitionstart|transitionend|transitionrun|transitioncancel)$/, 129 | ] 130 | 131 | return standardPatterns.some((pattern) => pattern.test(lowerEventName)) 132 | } 133 | 134 | export { generateIdUsing, sameUrlPath, generateId, except, only, rejectNullValues, kebabCase, isStandardDomEvent } 135 | -------------------------------------------------------------------------------- /src/ModalConfig.php: -------------------------------------------------------------------------------- 1 | type = ModalType::Modal; 46 | 47 | return $this; 48 | } 49 | 50 | /** 51 | * Sets the type to Slideover. 52 | */ 53 | public function slideover(): self 54 | { 55 | $this->type = ModalType::Slideover; 56 | 57 | return $this; 58 | } 59 | 60 | /** 61 | * Controls the visibility of the close button in the modal. 62 | */ 63 | public function closeButton(?bool $closeButton = true): self 64 | { 65 | $this->closeButton = $closeButton; 66 | 67 | return $this; 68 | } 69 | 70 | /** 71 | * Determines if the modal requires an explicit closing action, e.g. 72 | * no clicking outside the modal to close it and no escape key to close it. 73 | */ 74 | public function closeExplicitly(?bool $closeExplicitly = true): self 75 | { 76 | $this->closeExplicitly = $closeExplicitly; 77 | 78 | return $this; 79 | } 80 | 81 | /** 82 | * Sets the maximum width of the modal using Tailwind conventions. 83 | */ 84 | public function maxWidth(?string $maxWidth): self 85 | { 86 | if (! in_array($maxWidth, ['sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl', '7xl'])) { 87 | throw new InvalidArgumentException('Invalid max width provided. Please use a value between sm and 7xl.'); 88 | } 89 | 90 | $this->maxWidth = $maxWidth; 91 | 92 | return $this; 93 | } 94 | 95 | /** 96 | * Defines custom padding classes for the modal content 97 | */ 98 | public function paddingClasses(?string $paddingClasses): self 99 | { 100 | $this->paddingClasses = $paddingClasses; 101 | 102 | return $this; 103 | } 104 | 105 | /** 106 | * Sets custom classes for the modal panel element 107 | */ 108 | public function panelClasses(?string $panelClasses): self 109 | { 110 | $this->panelClasses = $panelClasses; 111 | 112 | return $this; 113 | } 114 | 115 | /** 116 | * Sets the position of the modal on the screen 117 | */ 118 | public function position(?ModalPosition $position): self 119 | { 120 | $this->position = $position; 121 | 122 | return $this; 123 | } 124 | 125 | /** 126 | * Positions the modal at the bottom of the screen 127 | */ 128 | public function bottom(): self 129 | { 130 | return $this->position(ModalPosition::Bottom); 131 | } 132 | 133 | /** 134 | * Centers the modal on the screen 135 | */ 136 | public function center(): self 137 | { 138 | return $this->position(ModalPosition::Center); 139 | } 140 | 141 | /** 142 | * Positions the slideover at the left side of the screen 143 | */ 144 | public function left(): self 145 | { 146 | return $this->position(ModalPosition::Left); 147 | } 148 | 149 | /** 150 | * Positions the slideover at the right side of the screen 151 | */ 152 | public function right(): self 153 | { 154 | return $this->position(ModalPosition::Right); 155 | } 156 | 157 | /** 158 | * Positions the modal at the top of the screen 159 | */ 160 | public function top(): self 161 | { 162 | return $this->position(ModalPosition::Top); 163 | } 164 | 165 | /** 166 | * Converts the modal configuration to an array. 167 | */ 168 | public function toArray(): array 169 | { 170 | return [ 171 | 'type' => $this->type?->value, 172 | 'modal' => $this->type instanceof ModalType && $this->type === ModalType::Modal, 173 | 'slideover' => $this->type instanceof ModalType && $this->type === ModalType::Slideover, 174 | 'closeButton' => $this->closeButton, 175 | 'closeExplicitly' => $this->closeExplicitly, 176 | 'maxWidth' => $this->maxWidth, 177 | 'paddingClasses' => $this->paddingClasses, 178 | 'panelClasses' => $this->panelClasses, 179 | 'position' => $this->position?->value, 180 | ]; 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /vue/src/Modal.vue: -------------------------------------------------------------------------------- 1 | 65 | 66 | 150 | -------------------------------------------------------------------------------- /vue/src/HeadlessModal.vue: -------------------------------------------------------------------------------- 1 | 153 | 154 | 179 | -------------------------------------------------------------------------------- /react/src/Modal.jsx: -------------------------------------------------------------------------------- 1 | import { Dialog, Transition, TransitionChild } from '@headlessui/react' 2 | import { forwardRef, useRef, useImperativeHandle } from 'react' 3 | import HeadlessModal from './HeadlessModal' 4 | import ModalContent from './ModalContent' 5 | import SlideoverContent from './SlideoverContent' 6 | 7 | const Modal = forwardRef(({ name, children, onFocus = null, onBlur = null, onClose = null, onSuccess = null, onAfterLeave = null, ...props }, ref) => { 8 | const renderChildren = (contentProps) => { 9 | if (typeof children === 'function') { 10 | return children(contentProps) 11 | } 12 | 13 | return children 14 | } 15 | 16 | const headlessModalRef = useRef(null) 17 | 18 | useImperativeHandle(ref, () => headlessModalRef.current, [headlessModalRef]) 19 | 20 | return ( 21 | 30 | {({ 31 | afterLeave, 32 | close, 33 | config, 34 | emit, 35 | getChildModal, 36 | getParentModal, 37 | id, 38 | index, 39 | isOpen, 40 | modalContext, 41 | onTopOfStack, 42 | reload, 43 | setOpen, 44 | shouldRender, 45 | }) => ( 46 | 51 | (config.closeExplicitly ? null : close())} 55 | data-inertiaui-modal-id={id} 56 | data-inertiaui-modal-index={index} 57 | > 58 | {/* Only transition the backdrop for the first modal in the stack */} 59 | {index === 0 ? ( 60 | 68 | {onTopOfStack ? ( 69 | 129 | 130 | )} 131 | 132 | ) 133 | }) 134 | 135 | Modal.displayName = 'Modal' 136 | export default Modal 137 | -------------------------------------------------------------------------------- /react/src/HeadlessModal.jsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useState, forwardRef, useImperativeHandle, useEffect, useRef } from 'react' 2 | import { getConfig, getConfigByType } from './config' 3 | import { useModalIndex } from './ModalRenderer.jsx' 4 | import { useModalStack } from './ModalRoot.jsx' 5 | import ModalRenderer from './ModalRenderer' 6 | 7 | const HeadlessModal = forwardRef(({ name, children, onFocus = null, onBlur = null, onClose = null, onSuccess = null, ...props }, ref) => { 8 | const modalIndex = useModalIndex() 9 | const { stack, registerLocalModal, removeLocalModal } = useModalStack() 10 | 11 | const [localModalContext, setLocalModalContext] = useState(null) 12 | const modalContext = useMemo(() => (name ? localModalContext : stack[modalIndex]), [name, localModalContext, modalIndex, stack]) 13 | 14 | const nextIndex = useMemo(() => { 15 | return stack.find((m) => m.shouldRender && m.index > modalContext?.index)?.index 16 | }, [modalIndex, stack]) 17 | 18 | const configSlideover = useMemo(() => modalContext?.config.slideover ?? props.slideover ?? getConfig('type') === 'slideover', [props.slideover]) 19 | 20 | const config = useMemo( 21 | () => ({ 22 | slideover: configSlideover, 23 | closeButton: props.closeButton ?? getConfigByType(configSlideover, 'closeButton'), 24 | closeExplicitly: props.closeExplicitly ?? getConfigByType(configSlideover, 'closeExplicitly'), 25 | maxWidth: props.maxWidth ?? getConfigByType(configSlideover, 'maxWidth'), 26 | paddingClasses: props.paddingClasses ?? getConfigByType(configSlideover, 'paddingClasses'), 27 | panelClasses: props.panelClasses ?? getConfigByType(configSlideover, 'panelClasses'), 28 | position: props.position ?? getConfigByType(configSlideover, 'position'), 29 | ...modalContext?.config, 30 | }), 31 | [props, modalContext?.config], 32 | ) 33 | 34 | useEffect(() => { 35 | if (name) { 36 | let removeListeners = null 37 | 38 | registerLocalModal(name, (localContext) => { 39 | removeListeners = localContext.registerEventListenersFromProps(props) 40 | setLocalModalContext(localContext) 41 | }) 42 | 43 | return () => { 44 | removeListeners?.() 45 | removeListeners = null 46 | removeLocalModal(name) 47 | } 48 | } 49 | 50 | return modalContext.registerEventListenersFromProps(props) 51 | }, [name]) 52 | 53 | // Store the latest modalContext in a ref to maintain reference 54 | const modalContextRef = useRef(modalContext) 55 | 56 | // Update the ref whenever modalContext changes 57 | useEffect(() => { 58 | modalContextRef.current = modalContext 59 | }, [modalContext]) 60 | 61 | useEffect(() => { 62 | if (modalContext !== null) { 63 | modalContext.isOpen ? onSuccess?.() : onClose?.() 64 | } 65 | }, [modalContext?.isOpen]) 66 | 67 | const [rendered, setRendered] = useState(false) 68 | 69 | useEffect(() => { 70 | if (rendered && modalContext !== null && modalContext.isOpen) { 71 | modalContext.onTopOfStack ? onFocus?.() : onBlur?.() 72 | } 73 | 74 | setRendered(true) 75 | }, [modalContext?.onTopOfStack]) 76 | 77 | useImperativeHandle( 78 | ref, 79 | () => ({ 80 | afterLeave: () => modalContextRef.current?.afterLeave(), 81 | close: () => modalContextRef.current?.close(), 82 | emit: (...args) => modalContextRef.current?.emit(...args), 83 | getChildModal: () => modalContextRef.current?.getChildModal(), 84 | getParentModal: () => modalContextRef.current?.getParentModal(), 85 | reload: (...args) => modalContextRef.current?.reload(...args), 86 | setOpen: () => modalContextRef.current?.setOpen(), 87 | 88 | get id() { 89 | return modalContextRef.current?.id 90 | }, 91 | get index() { 92 | return modalContextRef.current?.index 93 | }, 94 | get isOpen() { 95 | return modalContextRef.current?.isOpen 96 | }, 97 | get config() { 98 | return modalContextRef.current?.config 99 | }, 100 | get modalContext() { 101 | return modalContextRef.current 102 | }, 103 | get onTopOfStack() { 104 | return modalContextRef.current?.onTopOfStack 105 | }, 106 | get shouldRender() { 107 | return modalContextRef.current?.shouldRender 108 | }, 109 | }), 110 | [modalContext], 111 | ) 112 | 113 | return ( 114 | modalContext?.shouldRender && ( 115 | <> 116 | {typeof children === 'function' 117 | ? children({ 118 | afterLeave: modalContext.afterLeave, 119 | close: modalContext.close, 120 | config, 121 | emit: modalContext.emit, 122 | getChildModal: modalContext.getChildModal, 123 | getParentModal: modalContext.getParentModal, 124 | id: modalContext.id, 125 | index: modalContext.index, 126 | isOpen: modalContext.isOpen, 127 | modalContext, 128 | onTopOfStack: modalContext.onTopOfStack, 129 | reload: modalContext.reload, 130 | setOpen: modalContext.setOpen, 131 | shouldRender: modalContext.shouldRender, 132 | }) 133 | : children} 134 | 135 | {/* Next modal in the stack */} 136 | {nextIndex && } 137 | 138 | ) 139 | ) 140 | }) 141 | 142 | HeadlessModal.displayName = 'HeadlessModal' 143 | export default HeadlessModal 144 | -------------------------------------------------------------------------------- /vue/tests/helpers.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest' 2 | import { except, only, rejectNullValues, kebabCase, isStandardDomEvent } from '../src/helpers' 3 | 4 | describe('helpers', () => { 5 | describe('except', () => { 6 | it('should return an object without the specified keys', () => { 7 | const obj = { a: 1, b: 2, c: 3 } 8 | expect(except(obj, ['b'])).toEqual({ a: 1, c: 3 }) 9 | }) 10 | 11 | it('should return an empty object if all keys are excluded', () => { 12 | const obj = { a: 1, b: 2 } 13 | expect(except(obj, ['a', 'b'])).toEqual({}) 14 | }) 15 | 16 | it('should return the same object if no keys are excluded', () => { 17 | const obj = { a: 1, b: 2 } 18 | expect(except(obj, [])).toEqual(obj) 19 | }) 20 | 21 | it('should return an array without the specified elements', () => { 22 | const arr = ['a', 'b', 'c', 'd'] 23 | expect(except(arr, ['b', 'd'])).toEqual(['a', 'c']) 24 | }) 25 | 26 | it('should return an empty array if all elements are excluded', () => { 27 | const arr = ['a', 'b'] 28 | expect(except(arr, ['a', 'b'])).toEqual([]) 29 | }) 30 | 31 | it('should return the same array if no elements are excluded', () => { 32 | const arr = ['a', 'b'] 33 | expect(except(arr, [])).toEqual(arr) 34 | }) 35 | }) 36 | 37 | describe('only', () => { 38 | it('should return an object with only the specified keys', () => { 39 | const obj = { a: 1, b: 2, c: 3 } 40 | expect(only(obj, ['a', 'c'])).toEqual({ a: 1, c: 3 }) 41 | }) 42 | 43 | it('should return an empty object if no keys are specified', () => { 44 | const obj = { a: 1, b: 2 } 45 | expect(only(obj, [])).toEqual({}) 46 | }) 47 | 48 | it('should ignore keys that do not exist in the object', () => { 49 | const obj = { a: 1, b: 2 } 50 | expect(only(obj, ['a', 'c'])).toEqual({ a: 1 }) 51 | }) 52 | 53 | it('should return an array with only the specified elements', () => { 54 | const arr = ['a', 'b', 'c', 'd'] 55 | expect(only(arr, ['b', 'd'])).toEqual(['b', 'd']) 56 | }) 57 | 58 | it('should return an empty array if no elements are specified', () => { 59 | const arr = ['a', 'b'] 60 | expect(only(arr, [])).toEqual([]) 61 | }) 62 | 63 | it('should ignore elements that do not exist in the array', () => { 64 | const arr = ['a', 'b'] 65 | expect(only(arr, ['a', 'c'])).toEqual(['a']) 66 | }) 67 | }) 68 | 69 | describe('rejectNullValues', () => { 70 | it('should remove keys with null values from an object', () => { 71 | const obj = { a: 1, b: null, c: 3 } 72 | expect(rejectNullValues(obj)).toEqual({ a: 1, c: 3 }) 73 | }) 74 | 75 | it('should return the same object if no null values exist', () => { 76 | const obj = { a: 1, b: 2 } 77 | expect(rejectNullValues(obj)).toEqual(obj) 78 | }) 79 | 80 | it('should return an empty object if all values are null', () => { 81 | const obj = { a: null, b: null } 82 | expect(rejectNullValues(obj)).toEqual({}) 83 | }) 84 | 85 | it('should remove null values from an array', () => { 86 | const arr = [1, null, 3, null, 5] 87 | expect(rejectNullValues(arr)).toEqual([1, 3, 5]) 88 | }) 89 | 90 | it('should return the same array if no null values exist', () => { 91 | const arr = [1, 2, 3] 92 | expect(rejectNullValues(arr)).toEqual([1, 2, 3]) 93 | }) 94 | 95 | it('should return an empty array if all values are null', () => { 96 | const arr = [null, null, null] 97 | expect(rejectNullValues(arr)).toEqual([]) 98 | }) 99 | }) 100 | 101 | describe('kebabCase', () => { 102 | it.each([ 103 | // Basic camelCase/PascalCase 104 | ['camelCase', 'camel-case'], 105 | ['ThisIsPascalCase', 'this-is-pascal-case'], 106 | 107 | // With numbers 108 | ['user123Name', 'user123-name'], 109 | ['FirstName1', 'first-name1'], 110 | 111 | // With acronyms 112 | ['parseXMLDocument', 'parse-x-m-l-document'], 113 | 114 | // Mixed cases and special chars 115 | ['snake_case_value', 'snake-case-value'], 116 | ['already-kebab-case', 'already-kebab-case'], 117 | ['UPPERCASE', 'u-p-p-e-r-c-a-s-e'], 118 | ['multiple__underscores', 'multiple-underscores'], 119 | ])('should convert %s to %s', (input, expected) => { 120 | expect(kebabCase(input)).toBe(expected) 121 | }) 122 | }) 123 | 124 | describe('isStandardDomEvent', () => { 125 | it('should identify standard DOM events', () => { 126 | expect(isStandardDomEvent('onClick')).toBe(true) 127 | expect(isStandardDomEvent('onMouseOver')).toBe(true) 128 | expect(isStandardDomEvent('onKeyDown')).toBe(true) 129 | expect(isStandardDomEvent('onFocus')).toBe(true) 130 | expect(isStandardDomEvent('onSubmit')).toBe(true) 131 | expect(isStandardDomEvent('onTouchStart')).toBe(true) 132 | expect(isStandardDomEvent('onDragStart')).toBe(true) 133 | expect(isStandardDomEvent('onAnimationEnd')).toBe(true) 134 | }) 135 | 136 | it('should reject custom modal events', () => { 137 | expect(isStandardDomEvent('onUserUpdated')).toBe(false) 138 | expect(isStandardDomEvent('onModalReady')).toBe(false) 139 | expect(isStandardDomEvent('onDataRefresh')).toBe(false) 140 | expect(isStandardDomEvent('onCustomEvent')).toBe(false) 141 | }) 142 | 143 | it('should be case insensitive', () => { 144 | expect(isStandardDomEvent('onclick')).toBe(true) 145 | expect(isStandardDomEvent('ONCLICK')).toBe(true) 146 | expect(isStandardDomEvent('OnClick')).toBe(true) 147 | }) 148 | 149 | it('should work in different environments', () => { 150 | // Mock Node.js environment (no window or document) 151 | const originalWindow = global.window 152 | const originalDocument = global.document 153 | 154 | delete global.window 155 | delete global.document 156 | 157 | // Should fall back to regex patterns 158 | expect(isStandardDomEvent('onClick')).toBe(true) 159 | expect(isStandardDomEvent('onUserUpdated')).toBe(false) 160 | 161 | // Restore globals 162 | global.window = originalWindow 163 | global.document = originalDocument 164 | }) 165 | 166 | it('should work with document.createElement fallback', () => { 167 | // Mock SSR environment (document available, no window) 168 | const originalWindow = global.window 169 | delete global.window 170 | 171 | // Should use document.createElement test 172 | expect(isStandardDomEvent('onClick')).toBe(true) 173 | expect(isStandardDomEvent('onUserUpdated')).toBe(false) 174 | 175 | // Restore window 176 | global.window = originalWindow 177 | }) 178 | }) 179 | }) 180 | -------------------------------------------------------------------------------- /src/Modal.php: -------------------------------------------------------------------------------- 1 | Callbacks to run before the base URL is rerendered. 32 | */ 33 | protected static array $beforeBaseRerenderCallbacks = []; 34 | 35 | /** 36 | * @var array Middleware to exclude when dispatching the base URL request. 37 | */ 38 | protected static array $excludeMiddlewareOnBaseUrl = []; 39 | 40 | public function __construct(protected string $component, protected array $props = []) 41 | { 42 | // 43 | } 44 | 45 | /** 46 | * Register a callback to run before the base URL is rerendered. 47 | */ 48 | public static function beforeBaseRerender(callable $callback): void 49 | { 50 | static::$beforeBaseRerenderCallbacks[] = $callback; 51 | } 52 | 53 | /** 54 | * Register middleware to exclude when dispatching the base URL request. 55 | */ 56 | public static function excludeMiddlewareOnBaseUrl(array|string $middleware): void 57 | { 58 | static::$excludeMiddlewareOnBaseUrl = array_merge(static::$excludeMiddlewareOnBaseUrl, Arr::wrap($middleware)); 59 | } 60 | 61 | /** 62 | * Get the middleware to exclude when dispatching the base URL request. 63 | */ 64 | public static function getMiddlewareToExcludeOnBaseUrl(): array 65 | { 66 | return static::$excludeMiddlewareOnBaseUrl; 67 | } 68 | 69 | /** 70 | * Set the base URL for the modal. 71 | */ 72 | public function baseUrl(string $baseUrl): static 73 | { 74 | $this->baseUrl = $baseUrl; 75 | 76 | return $this; 77 | } 78 | 79 | /** 80 | * Set the base URL for the modal using a named route. 81 | */ 82 | public function baseRoute(\BackedEnum|string $name, mixed $parameters = [], bool $absolute = true): static 83 | { 84 | $this->baseUrl = route($name, $parameters, $absolute); 85 | 86 | return $this; 87 | } 88 | 89 | /** 90 | * Get the base URL for the modal. 91 | */ 92 | public function getBaseUrl(): ?string 93 | { 94 | return $this->baseUrl; 95 | } 96 | 97 | /** 98 | * Resolve the base URL for the modal. 99 | * 100 | * Used to render the 'background' page as well as where to redirect after closing the modal. 101 | */ 102 | public function resolveBaseUrl(Request $request): ?string 103 | { 104 | // Check each source in priority order, skipping any that match current path (prevents infinite loops) 105 | $candidates = [ 106 | $request->header(self::HEADER_BASE_URL), 107 | $request->header('referer'), 108 | $this->getBaseUrl(), 109 | ]; 110 | 111 | foreach ($candidates as $baseUrl) { 112 | if ($baseUrl !== null && ! $request->is($this->extractPath($baseUrl))) { 113 | return $baseUrl; 114 | } 115 | } 116 | 117 | return null; 118 | } 119 | 120 | /** 121 | * Extract and normalize the path from a URL for comparison. 122 | */ 123 | protected function extractPath(string $url): string 124 | { 125 | $decoded = rawurldecode(trim((string) parse_url($url, PHP_URL_PATH), '/')); 126 | 127 | return $decoded !== '' ? $decoded : '/'; 128 | } 129 | 130 | /** 131 | * Create an HTTP response with either the modal or the modal's base URL with the modal data. 132 | */ 133 | public function toResponse($request) 134 | { 135 | /** @var InertiaResponse $modal */ 136 | $modal = inertia()->render($this->component, $this->props); 137 | 138 | $baseUrl = $this->resolveBaseUrl($request); 139 | 140 | if (in_array($request->header(self::HEADER_USE_ROUTER), [0, '0'], true) || blank($baseUrl)) { 141 | // Also used for reloading modal props... 142 | return $this->extractMeta($modal->toResponse($request)); 143 | } 144 | 145 | inertia()->share('_inertiaui_modal', [ 146 | // @phpstan-ignore-next-line 147 | ...($modalData = $modal->toArray()), 148 | 'id' => $request->header(static::HEADER_MODAL), 149 | 'baseUrl' => $baseUrl, 150 | ]); 151 | 152 | $response = app(DispatchBaseUrlRequest::class)($request, $baseUrl); 153 | 154 | // Spoof the base URL to the modal's URL 155 | return match (true) { 156 | $response instanceof JsonResponse => $this->toJsonResponse($response, $modalData['url']), 157 | $response instanceof IlluminateResponse => $this->toViewResponse($request, $response, $modalData['url']), 158 | default => $response, 159 | }; 160 | } 161 | 162 | /** 163 | * Extract the meta data from the JSON response and set it in the 'meta' key. 164 | */ 165 | protected function extractMeta(SymfonyResponse $response): SymfonyResponse 166 | { 167 | if (! $response instanceof JsonResponse) { 168 | return $response; 169 | } 170 | 171 | $data = $response->getData(true); 172 | $data['meta'] = []; 173 | 174 | foreach (['mergeProps', 'deferredProps', 'cache'] as $key) { 175 | if (! array_key_exists($key, $data)) { 176 | continue; 177 | } 178 | 179 | $data['meta'][$key] = $data[$key]; 180 | unset($data[$key]); 181 | } 182 | 183 | if (empty($data['meta'])) { 184 | $data['meta'] = (object) []; 185 | } 186 | 187 | return $response->setData($data); 188 | } 189 | 190 | /** 191 | * Replace the URL in the JSON response with the modal's URL so the 192 | * Inertia front-end library won't redirect back to the base URL. 193 | */ 194 | protected function toJsonResponse(JsonResponse $response, string $url): JsonResponse 195 | { 196 | return $response->setData([ 197 | ...$response->getData(true), 198 | 'url' => $url, 199 | ]); 200 | } 201 | 202 | /** 203 | * Replace the URL in the View Response with the modal's URL so the 204 | * Inertia front-end library won't redirect back to the base URL. 205 | */ 206 | protected function toViewResponse(Request $request, IlluminateResponse $response, string $url): IlluminateResponse 207 | { 208 | $originalContent = $response->getOriginalContent(); 209 | 210 | if (! $originalContent instanceof View) { 211 | return $response; 212 | } 213 | 214 | $viewData = $originalContent->getData(); 215 | $viewData['page']['url'] = $url; 216 | 217 | foreach (static::$beforeBaseRerenderCallbacks as $callback) { 218 | $callback($request, $response); 219 | } 220 | 221 | return ResponseFactory::view($originalContent->getName(), $viewData); 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `inertiaui/modal` will be documented in this file. 4 | 5 | ## 0.21.1 - 2025-06-17 6 | 7 | ### What's Changed 8 | 9 | * Bump dependabot/fetch-metadata from 2.3.0 to 2.4.0 by @dependabot in https://github.com/inertiaui/modal/pull/119 10 | * Bump stefanzweifel/git-auto-commit-action from 5 to 6 by @dependabot in https://github.com/inertiaui/modal/pull/122 11 | * Remove Pint conflict (see #120) by @pascalbaljet in https://github.com/inertiaui/modal/pull/123 12 | 13 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.21.0...0.21.1 14 | 15 | ## 0.21.0 - 2025-03-28 16 | 17 | ### What's Changed 18 | 19 | * Support for deferred, lazy, and optional props by @pascalbaljet in https://github.com/inertiaui/modal/pull/110 20 | * Support lazy props in Inertia v1 by @pascalbaljet in https://github.com/inertiaui/modal/pull/111 21 | 22 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.20.5...0.21.0 23 | 24 | ## 0.20.5 - 2025-03-28 25 | 26 | ### What's Changed 27 | 28 | * Deferred props improvements: Added more tests + improvements for loading directly by @pascalbaljet in https://github.com/inertiaui/modal/pull/109 29 | 30 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.20.4...0.20.5 31 | 32 | ## 0.20.4 - 2025-03-27 33 | 34 | ### What's Changed 35 | 36 | * Match baseRoute parameters with Laravel route parameters by @SuperDJ in https://github.com/inertiaui/modal/pull/98 37 | * Fixes react programmatic usage example by @PedroAugustoRamalhoDuarte in https://github.com/inertiaui/modal/pull/108 38 | * Undefined array key with deferred prop by @SuperDJ in https://github.com/inertiaui/modal/pull/89 39 | 40 | ### New Contributors 41 | 42 | * @SuperDJ made their first contribution in https://github.com/inertiaui/modal/pull/98 43 | * @PedroAugustoRamalhoDuarte made their first contribution in https://github.com/inertiaui/modal/pull/108 44 | 45 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.20.3...0.20.4 46 | 47 | ## 0.20.3 - 2025-03-27 48 | 49 | ### What's Changed 50 | 51 | * Restore `VerifyCsrfToken` middleware 52 | 53 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.20.2...0.20.3 54 | 55 | ## 0.20.2 - 2025-03-27 56 | 57 | ### What's Changed 58 | 59 | * Better middleware resolving by @pascalbaljet 60 | 61 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.20.1...0.20.2 62 | 63 | ## 0.20.1 - 2025-03-27 64 | 65 | ### What's Changed 66 | 67 | * Improve middleware exclusion by @pascalbaljet in https://github.com/inertiaui/modal/pull/107 68 | 69 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.20.0...0.20.1 70 | 71 | ## 0.20.0 - 2025-03-26 72 | 73 | ### What's Changed 74 | 75 | * Take `referer` header into account when resolving Base URL by @pascalbaljet in https://github.com/inertiaui/modal/pull/104 76 | * Further improvement of modal prop filtering by @pascalbaljet in https://github.com/inertiaui/modal/pull/105 77 | * Add support for `data` and `headers` when reloading modal props by @pascalbaljet in https://github.com/inertiaui/modal/pull/106 78 | 79 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.19.5...0.20.0 80 | 81 | ## 0.19.5 - 2025-03-26 82 | 83 | ### What's Changed 84 | 85 | * Upgrade to Vite 6 by @pascalbaljet in https://github.com/inertiaui/modal/pull/102 86 | * Filter modal props (fix for #101) by @pascalbaljet in https://github.com/inertiaui/modal/pull/103 87 | 88 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.19.4...0.19.5 89 | 90 | ## 0.19.4 - 2025-02-18 91 | 92 | ### What's Changed 93 | 94 | * Bump dependabot/fetch-metadata from 2.2.0 to 2.3.0 by @dependabot in https://github.com/inertiaui/modal/pull/88 95 | * Support for Laravel 12 by @pascalbaljet in https://github.com/inertiaui/modal/pull/95 96 | 97 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.19.3...0.19.4 98 | 99 | ## 0.19.3 - 2025-01-22 100 | 101 | ### What's Changed 102 | 103 | * Improve visiting same url by @pascalbaljet in https://github.com/inertiaui/modal/pull/86 104 | 105 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.19.2...0.19.3 106 | 107 | ## 0.19.2 - 2025-01-22 108 | 109 | ### What's Changed 110 | 111 | * Improve visiting a similar Modal URL by @pascalbaljet in https://github.com/inertiaui/modal/pull/85 112 | 113 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.19.1...0.19.2 114 | 115 | ## 0.19.1 - 2025-01-22 116 | 117 | * [Call toArray() on modal props](https://github.com/inertiaui/modal/commit/b33b6ab1cbc3ab9bedf4a43d49766ebedad476eb) 118 | 119 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.19.0...0.19.1 120 | 121 | ## 0.19.0 - 2025-01-22 122 | 123 | ### What's Changed 124 | 125 | * Revert #77 by @pascalbaljet in https://github.com/inertiaui/modal/pull/84 126 | 127 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.18.1...0.19.0 128 | 129 | ## 0.18.1 - 2025-01-19 130 | 131 | ### What's Changed 132 | 133 | * Update docs by @yoeriboven in https://github.com/inertiaui/modal/pull/69 134 | * Remove manual `Router` binding by @pascalbaljet in 9914962 135 | * Remove `pointer-events*` classes by @pascalbaljet in 71d51d3 136 | 137 | ### New Contributors 138 | 139 | * @yoeriboven made their first contribution in https://github.com/inertiaui/modal/pull/69 140 | 141 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.18.0...0.18.1 142 | 143 | ## 0.17.0 - 2025-01-08 144 | 145 | ### What's Changed 146 | 147 | * Reduce dependencies by @pascalbaljet in https://github.com/inertiaui/modal/pull/77 148 | 149 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.16.0...0.17.0 150 | 151 | ## 0.16.0 - 2025-01-07 152 | 153 | Revert #70 154 | 155 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.15.0...0.16.0 156 | 157 | ## 0.15.0 - 2025-01-07 158 | 159 | ### What's Changed 160 | 161 | * Remove `radix-vue` dependency by @pascalbaljet in https://github.com/inertiaui/modal/pull/70 162 | 163 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.14.0...0.15.0 164 | 165 | ## 0.14.0 - 2024-12-17 166 | 167 | ### What's Changed 168 | 169 | * Bump Inertia.js versions by @pascalbaljet in https://github.com/inertiaui/modal/pull/67 170 | 171 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.13.1...0.14.0 172 | 173 | ## 0.13.1 - 2024-11-20 174 | 175 | ### What's Changed 176 | 177 | * Fix for referring local modal (see #53) by @pascalbaljet in https://github.com/inertiaui/modal/pull/57 178 | 179 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.13.0...0.13.1 180 | 181 | ## 0.13.0 - 2024-11-19 182 | 183 | ### What's Changed 184 | 185 | * Custom app mounting by @pascalbaljet in https://github.com/inertiaui/modal/pull/56 186 | 187 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.12.1...0.13.0 188 | 189 | ## 0.12.1 - 2024-11-19 190 | 191 | * Allow Inertia v2 as dev dependency (see #46) 192 | 193 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.12.0...0.12.1 194 | 195 | ## 0.12.0 - 2024-11-19 196 | 197 | ### What's Changed 198 | 199 | * Prevent double cookie encryption (see #20) by @pascalbaljet in https://github.com/inertiaui/modal/pull/55 200 | 201 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.11.1...0.12.0 202 | 203 | ## 0.11.1 - 2024-11-06 204 | 205 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.11.0...0.11.1 206 | 207 | ## 0.11.0 - 2024-11-06 208 | 209 | ### What's Changed 210 | 211 | * Improve GitHub build by @pascalbaljet in https://github.com/inertiaui/modal/pull/48 212 | 213 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.10.0...0.11.0 214 | 215 | ## 0.10.0 - 2024-11-06 216 | 217 | ### What's Changed 218 | 219 | * Introduced `DuskModalMacros` for better testing by @pascalbaljet in https://github.com/inertiaui/modal/pull/47 220 | 221 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.9.1...0.10.0 222 | 223 | ## 0.9.1 - 2024-11-03 224 | 225 | * Support callable in `ModalVisit::config()` 226 | 227 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.9.0...0.9.1 228 | 229 | ## 0.9.0 - 2024-11-03 230 | 231 | * Introduced `ModalVisit` class 232 | 233 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.8.0...0.9.0 234 | 235 | ## 0.8.0 - 2024-11-02 236 | 237 | * Added `ModalConfig` class 238 | 239 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.7.0...0.8.0 240 | 241 | ## 0.7.0 - 2024-10-31 242 | 243 | ### What's Changed 244 | 245 | * Listen for events using `visitModal()` by @pascalbaljet in https://github.com/inertiaui/modal/pull/44 246 | 247 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.6.4...0.7.0 248 | 249 | ## 0.6.4 - 2024-10-31 250 | 251 | Rerelease of v0.6.3 252 | 253 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.6.3...0.6.4 254 | 255 | ## 0.6.3 - 2024-10-31 256 | 257 | ### What's Changed 258 | 259 | * Don't send the Base URL header on unrelated Axios requests by @pascalbaljet in https://github.com/inertiaui/modal/pull/43 260 | 261 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.6.2...0.6.3 262 | 263 | ## 0.6.2 - 2024-10-29 264 | 265 | ### What's Changed 266 | 267 | * Update props when redirecting back to same modal by @pascalbaljet in https://github.com/inertiaui/modal/pull/39 268 | * Fix `close-explicitly` in Vue + update tests (see #36) by @pascalbaljet in https://github.com/inertiaui/modal/pull/40 269 | 270 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.6.1...0.6.2 271 | 272 | ## 0.6.1 - 2024-10-28 273 | 274 | * Fix `navigate` config option (as object) (see #35) 275 | * Added `rtl` classes for slideover (see #34) 276 | 277 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.6.0...0.6.1 278 | 279 | ## 0.6.0 - 2024-10-28 280 | 281 | ### What's Changed 282 | 283 | * Added `useModal()` helper + renamed some props by @pascalbaljet in https://github.com/inertiaui/modal/pull/29 284 | * Sync React/Vue navigate hook by @pascalbaljet in https://github.com/inertiaui/modal/pull/33 285 | 286 | ### New Contributors 287 | 288 | * @dependabot made their first contribution in https://github.com/inertiaui/modal/pull/30 289 | 290 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.5.4...0.6.0 291 | 292 | ## 0.5.4 - 2024-10-25 293 | 294 | ### What's Changed 295 | 296 | * Session test by @pascalbaljet in https://github.com/inertiaui/modal/pull/26 297 | * Nested modal redirect fix by @pascalbaljet in https://github.com/inertiaui/modal/pull/27 298 | 299 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.5.3...0.5.4 300 | 301 | ## 0.5.3 - 2024-10-25 302 | 303 | ### What's Changed 304 | 305 | * Update basic-usage.md by @Froelund in https://github.com/inertiaui/modal/pull/10 306 | * Fix for `navigate` option in `visitModal()` by @pascalbaljet in https://github.com/inertiaui/modal/pull/21 307 | 308 | ### New Contributors 309 | 310 | * @Froelund made their first contribution in https://github.com/inertiaui/modal/pull/10 311 | 312 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.5.2...0.5.3 313 | 314 | ## 0.5.2 - 2024-10-23 315 | 316 | * Support for callbacks before rerendering the base route / url 317 | 318 | **Full Changelog**: https://github.com/inertiaui/modal/compare/0.5.0...0.5.2 319 | 320 | ## 0.5.0 - 2024-10-23 321 | 322 | ### What's Changed 323 | 324 | * Base Route / URL by @pascalbaljet in https://github.com/inertiaui/modal/pull/18 325 | 326 | **Full Changelog**: https://github.com/inertiaui/modal/commits/0.5.0 327 | 328 | ## 0.3.0 - 2024-10-02 329 | 330 | * Introduced `ModalRoot` component (breaking change!) 331 | * Introduced `visitModal` method 332 | 333 | ## 0.2.0 - 2024-09-30 334 | 335 | * Support for other HTTP methods and data in `ModalLink` 336 | -------------------------------------------------------------------------------- /vue/tests/modalStack.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach } from 'vitest' 2 | import { useModalStack, modalPropNames } from './../src/modalStack' 3 | import axios from 'axios' 4 | import { router } from '@inertiajs/vue3' 5 | import { usePage } from '@inertiajs/vue3' 6 | import { generateIdUsing } from '../src/helpers' 7 | 8 | vi.mock('@inertiajs/vue3', () => ({ 9 | router: { 10 | resolveComponent: vi.fn(), 11 | }, 12 | usePage: vi.fn(), 13 | })) 14 | 15 | vi.mock('axios') 16 | 17 | describe('modalStack', () => { 18 | let modalStack 19 | 20 | beforeEach(() => { 21 | modalStack = useModalStack() 22 | vi.clearAllMocks() 23 | }) 24 | 25 | afterEach(() => { 26 | modalStack.reset() 27 | }) 28 | 29 | describe('useModalStack', () => { 30 | it('should return an object with stack, push, and reset', () => { 31 | expect(modalStack).toHaveProperty('stack') 32 | expect(modalStack).toHaveProperty('push') 33 | expect(modalStack).toHaveProperty('reset') 34 | }) 35 | 36 | it('should have an empty stack initially', () => { 37 | expect(modalStack.stack.value).toHaveLength(0) 38 | }) 39 | }) 40 | 41 | describe('Modal', () => { 42 | it('should create a new modal and add it to the stack', () => { 43 | const component = { name: 'TestComponent' } 44 | const response = { props: {}, url: '/test', component: 'TestComponent', version: '1' } 45 | const config = { closeButton: true } 46 | const onClose = vi.fn() 47 | const afterLeave = vi.fn() 48 | 49 | const modal = modalStack.push(component, response, config, onClose, afterLeave) 50 | 51 | expect(modalStack.stack.value).toHaveLength(1) 52 | expect(modal).toHaveProperty('id') 53 | expect(modal.component).toBe(component) 54 | expect(modal.config).toBe(config) 55 | }) 56 | 57 | it('should generate unique ids for each modal', () => { 58 | const modal1 = modalStack.push({}, {}, {}) 59 | const modal2 = modalStack.push({}, {}, {}) 60 | 61 | expect(modal1.id).not.toBe(modal2.id) 62 | }) 63 | 64 | it('should correctly identify previous and next modals', () => { 65 | const modal1 = modalStack.push({}, {}, {}) 66 | modal1.show() 67 | const modal2 = modalStack.push({}, {}, {}) 68 | modal2.show() 69 | const modal3 = modalStack.push({}, {}, {}) 70 | modal3.show() 71 | 72 | expect(modal1.getParentModal()).toBeNull() 73 | expect(modal1.getChildModal().id).toBe(modal2.id) 74 | 75 | expect(modal2.getParentModal().id).toBe(modal1.id) 76 | expect(modal2.getChildModal().id).toBe(modal3.id) 77 | 78 | expect(modal3.getParentModal().id).toBe(modal2.id) 79 | expect(modal3.getChildModal()).toBeNull() 80 | }) 81 | 82 | it('should correctly determine if a modal is on top of the stack', () => { 83 | const modal1 = modalStack.push({}, {}, {}) 84 | expect(modal1.onTopOfStack.value).toBe(true) 85 | 86 | const modal2 = modalStack.push({}, {}, {}) 87 | modal2.show() 88 | expect(modal1.onTopOfStack.value).toBe(false) 89 | expect(modal2.onTopOfStack.value).toBe(true) 90 | 91 | const modal3 = modalStack.push({}, {}, {}) 92 | modal3.show() 93 | expect(modal1.onTopOfStack.value).toBe(false) 94 | expect(modal2.onTopOfStack.value).toBe(false) 95 | expect(modal3.onTopOfStack.value).toBe(true) 96 | }) 97 | 98 | it('should close a modal', () => { 99 | const onClose = vi.fn() 100 | const modal = modalStack.push({}, {}, {}, onClose) 101 | modal.show() // can't close a modal that is not open 102 | modal.close() 103 | 104 | expect(modal.isOpen).toBe(false) 105 | expect(onClose).toHaveBeenCalled() 106 | // it does not remove the modal from the stack immediately 107 | expect(modalStack.stack.value).toHaveLength(1) 108 | }) 109 | 110 | it('should remove a modal after leave', () => { 111 | const afterLeave = vi.fn() 112 | const modal = modalStack.push({}, {}, {}, () => {}, afterLeave) 113 | modal.afterLeave() 114 | 115 | expect(afterLeave).toHaveBeenCalled() 116 | expect(modalStack.stack.value).toHaveLength(0) 117 | }) 118 | 119 | it('should handle event listeners', () => { 120 | const modal = modalStack.push({}, {}, {}) 121 | const callback = vi.fn() 122 | 123 | modal.on('test', callback) 124 | modal.emit('test', 'arg') 125 | 126 | expect(callback).toHaveBeenCalledWith('arg') 127 | 128 | modal.off('test', callback) 129 | modal.emit('test', 'arg') 130 | 131 | expect(callback).toHaveBeenCalledTimes(1) 132 | }) 133 | 134 | it('should reload modal props with correct headers', async () => { 135 | generateIdUsing(() => 'inertiaui_modal_uuid') 136 | 137 | const response = { 138 | props: { test: 'initial', another: 'prop' }, 139 | url: '/test', 140 | component: 'TestComponent', 141 | version: '1', 142 | } 143 | const modal = modalStack.push({}, response, {}) 144 | 145 | vi.mocked(axios).mockResolvedValue({ 146 | data: { props: { test: 'updated', another: 'updated prop' } }, 147 | }) 148 | 149 | await modal.reload() 150 | 151 | expect(axios).toHaveBeenCalledWith({ 152 | method: 'get', 153 | url: '/test', 154 | data: {}, 155 | params: {}, 156 | headers: { 157 | Accept: 'text/html, application/xhtml+xml', 158 | 'X-Inertia': true, 159 | 'X-Inertia-Partial-Component': 'TestComponent', 160 | 'X-Inertia-Version': '1', 161 | 'X-Inertia-Partial-Data': 'test,another', 162 | 'X-InertiaUI-Modal': 'inertiaui_modal_uuid', 163 | 'X-InertiaUI-Modal-Use-Router': 0, 164 | 'X-InertiaUI-Modal-Base-Url': null, 165 | }, 166 | }) 167 | 168 | expect(modal.props.value.test).toBe('updated') 169 | expect(modal.props.value.another).toBe('updated prop') 170 | }) 171 | 172 | it('should reload modal props with "only" option', async () => { 173 | generateIdUsing(() => 'inertiaui_modal_uuid') 174 | 175 | const response = { 176 | props: { test: 'initial', another: 'prop' }, 177 | url: '/test', 178 | component: 'TestComponent', 179 | version: '1', 180 | } 181 | const modal = modalStack.push({}, response, {}) 182 | 183 | vi.mocked(axios).mockResolvedValue({ 184 | data: { props: { test: 'updated' } }, 185 | }) 186 | 187 | await modal.reload({ only: ['test'] }) 188 | 189 | expect(axios).toHaveBeenCalledWith({ 190 | method: 'get', 191 | url: '/test', 192 | data: {}, 193 | params: {}, 194 | headers: { 195 | Accept: 'text/html, application/xhtml+xml', 196 | 'X-Inertia': true, 197 | 'X-Inertia-Partial-Component': 'TestComponent', 198 | 'X-Inertia-Version': '1', 199 | 'X-Inertia-Partial-Data': 'test', 200 | 'X-InertiaUI-Modal': 'inertiaui_modal_uuid', 201 | 'X-InertiaUI-Modal-Use-Router': 0, 202 | 'X-InertiaUI-Modal-Base-Url': null, 203 | }, 204 | }) 205 | 206 | expect(modal.props.value.test).toBe('updated') 207 | expect(modal.props.value.another).toBe('prop') // This should not change 208 | }) 209 | 210 | it('should reload modal props with "except" option', async () => { 211 | generateIdUsing(() => 'inertiaui_modal_uuid') 212 | 213 | const response = { 214 | props: { test: 'initial', another: 'prop', third: 'value' }, 215 | url: '/test', 216 | component: 'TestComponent', 217 | version: '1', 218 | } 219 | const modal = modalStack.push({}, response, {}) 220 | 221 | vi.mocked(axios).mockResolvedValue({ 222 | data: { props: { test: 'updated', third: 'updated value' } }, 223 | }) 224 | 225 | await modal.reload({ except: ['another'] }) 226 | 227 | expect(axios).toHaveBeenCalledWith({ 228 | method: 'get', 229 | url: '/test', 230 | data: {}, 231 | params: {}, 232 | headers: { 233 | Accept: 'text/html, application/xhtml+xml', 234 | 'X-Inertia': true, 235 | 'X-Inertia-Partial-Component': 'TestComponent', 236 | 'X-Inertia-Version': '1', 237 | 'X-Inertia-Partial-Data': 'test,third', 238 | 'X-InertiaUI-Modal': 'inertiaui_modal_uuid', 239 | 'X-InertiaUI-Modal-Use-Router': 0, 240 | 'X-InertiaUI-Modal-Base-Url': null, 241 | }, 242 | }) 243 | 244 | expect(modal.props.value.test).toBe('updated') 245 | expect(modal.props.value.another).toBe('prop') // This should not change 246 | expect(modal.props.value.third).toBe('updated value') 247 | }) 248 | 249 | it('should make an Axios request and push a new modal', async () => { 250 | generateIdUsing(() => 'inertiaui_modal_uuid') 251 | 252 | const href = '/test-url' 253 | const method = 'get' 254 | const data = { key: 'value' } 255 | const headers = { 'Custom-Header': 'Test' } 256 | const config = { closeButton: true } 257 | const onClose = vi.fn() 258 | const onAfterLeave = vi.fn() 259 | 260 | const mockResponse = { 261 | data: { 262 | component: 'TestComponent', 263 | props: { testProp: 'value' }, 264 | url: '/test-url', 265 | version: '1', 266 | }, 267 | } 268 | 269 | const mockComponent = { name: 'TestComponent' } 270 | 271 | modalStack.setComponentResolver((component) => { 272 | expect(component).toBe('TestComponent') 273 | return router.resolveComponent(component) 274 | }) 275 | 276 | vi.mocked(axios).mockResolvedValue(mockResponse) 277 | vi.mocked(router.resolveComponent).mockResolvedValue(mockComponent) 278 | vi.mocked(usePage).mockReturnValue({ version: '1.0' }) 279 | 280 | const result = await modalStack.visit(href, method, data, headers, config, onClose, onAfterLeave) 281 | 282 | expect(axios).toHaveBeenCalledWith({ 283 | url: '/test-url?key=value', 284 | method: 'get', 285 | data: {}, 286 | headers: { 287 | 'Custom-Header': 'Test', 288 | Accept: 'text/html, application/xhtml+xml', 289 | 'X-Requested-With': 'XMLHttpRequest', 290 | 'X-Inertia': true, 291 | 'X-Inertia-Version': '1.0', 292 | 'X-InertiaUI-Modal': 'inertiaui_modal_uuid', 293 | 'X-InertiaUI-Modal-Use-Router': 0, 294 | 'X-InertiaUI-Modal-Base-Url': '', 295 | }, 296 | }) 297 | 298 | expect(result).toBeDefined() 299 | expect(result.component).toBe(mockComponent) 300 | expect(result.response).toEqual(mockResponse.data) 301 | expect(result.config).toEqual(config) 302 | expect(modalStack.stack.value).toHaveLength(1) 303 | }) 304 | 305 | it('should handle errors during the visit', async () => { 306 | const href = '/test-url' 307 | const method = 'get' 308 | 309 | const mockError = new Error('Network Error') 310 | vi.mocked(axios).mockRejectedValue(mockError) 311 | 312 | await expect(modalStack.visit(href, method)).rejects.toThrow('Network Error') 313 | expect(modalStack.stack.value).toHaveLength(0) 314 | }) 315 | }) 316 | 317 | describe('modalPropNames', () => { 318 | it('should contain the correct prop names', () => { 319 | expect(modalPropNames).toEqual(['closeButton', 'closeExplicitly', 'maxWidth', 'paddingClasses', 'panelClasses', 'position', 'slideover']) 320 | }) 321 | }) 322 | }) 323 | -------------------------------------------------------------------------------- /vue/src/modalStack.js: -------------------------------------------------------------------------------- 1 | import { computed, readonly, ref, markRaw, h, nextTick } from 'vue' 2 | import { generateId, except, kebabCase } from './helpers' 3 | import { router, usePage } from '@inertiajs/vue3' 4 | import * as InertiaVue from '@inertiajs/vue3' 5 | import { mergeDataIntoQueryString } from '@inertiajs/core' 6 | import { default as Axios } from 'axios' 7 | import ModalRoot from './ModalRoot.vue' 8 | 9 | let resolveComponent = null 10 | 11 | const pendingModalUpdates = ref({}) 12 | const baseUrl = ref(null) 13 | const baseModalsToWaitFor = ref({}) 14 | const stack = ref([]) 15 | const localModals = ref({}) 16 | 17 | const setComponentResolver = (resolver) => { 18 | resolveComponent = resolver 19 | } 20 | 21 | export const initFromPageProps = (pageProps) => { 22 | if (pageProps.resolveComponent) { 23 | resolveComponent = pageProps.resolveComponent 24 | } 25 | } 26 | 27 | class Modal { 28 | constructor(component, response, config, onClose, afterLeave) { 29 | this.id = response.id ?? generateId() 30 | this.isOpen = false 31 | this.shouldRender = false 32 | this.listeners = {} 33 | 34 | this.component = component 35 | this.props = ref(response.props) 36 | this.response = response 37 | this.config = config ?? {} 38 | this.onCloseCallback = onClose 39 | this.afterLeaveCallback = afterLeave 40 | 41 | if (pendingModalUpdates.value[this.id]) { 42 | this.config = { 43 | ...this.config, 44 | ...(pendingModalUpdates.value[this.id].config ?? {}), 45 | } 46 | 47 | const pendingOnClose = pendingModalUpdates.value[this.id].onClose 48 | const pendingOnAfterLeave = pendingModalUpdates.value[this.id].onAfterLeave 49 | 50 | if (pendingOnClose) { 51 | this.onCloseCallback = onClose 52 | ? () => { 53 | onClose() 54 | pendingOnClose() 55 | } 56 | : pendingOnClose 57 | } 58 | 59 | if (pendingOnAfterLeave) { 60 | this.afterLeaveCallback = afterLeave 61 | ? () => { 62 | afterLeave() 63 | pendingOnAfterLeave() 64 | } 65 | : pendingOnAfterLeave 66 | } 67 | 68 | delete pendingModalUpdates.value[this.id] 69 | } 70 | 71 | this.index = computed(() => stack.value.findIndex((m) => m.id === this.id)) 72 | this.onTopOfStack = computed(() => { 73 | if (stack.value.length < 2) { 74 | return true 75 | } 76 | 77 | const modals = stack.value.map((modal) => ({ id: modal.id, shouldRender: modal.shouldRender })) 78 | 79 | return modals.reverse().find((modal) => modal.shouldRender)?.id === this.id 80 | }) 81 | } 82 | 83 | getComponentPropKeys = () => { 84 | if (Array.isArray(this.component.props)) { 85 | return this.component.props 86 | } 87 | 88 | return this.component.props ? Object.keys(this.component.props) : [] 89 | } 90 | 91 | getParentModal = () => { 92 | const index = this.index.value 93 | 94 | if (index < 1) { 95 | // This is the first modal in the stack 96 | return null 97 | } 98 | 99 | // Find the first open modal before this one 100 | return stack.value 101 | .slice(0, index) 102 | .reverse() 103 | .find((modal) => modal.isOpen) 104 | } 105 | 106 | getChildModal = () => { 107 | const index = this.index.value 108 | 109 | if (index === stack.value.length - 1) { 110 | // This is the last modal in the stack 111 | return null 112 | } 113 | 114 | // Find the first open modal after this one 115 | return stack.value.slice(index + 1).find((modal) => modal.isOpen) 116 | } 117 | 118 | show = () => { 119 | const index = this.index.value 120 | 121 | if (index > -1) { 122 | if (stack.value[index].isOpen) { 123 | // Only open if the modal is closed 124 | return 125 | } 126 | 127 | stack.value[index].isOpen = true 128 | stack.value[index].shouldRender = true 129 | } 130 | } 131 | 132 | close = () => { 133 | const index = this.index.value 134 | 135 | if (index > -1) { 136 | if (!stack.value[index].isOpen) { 137 | // Only close if the modal is open 138 | return 139 | } 140 | 141 | Object.keys(this.listeners).forEach((event) => { 142 | this.off(event) 143 | }) 144 | 145 | stack.value[index].isOpen = false 146 | this.onCloseCallback?.() 147 | this.onCloseCallback = null 148 | } 149 | } 150 | 151 | setOpen = (open) => { 152 | open ? this.show() : this.close() 153 | } 154 | 155 | afterLeave = () => { 156 | const index = this.index.value 157 | 158 | if (index > -1) { 159 | if (stack.value[index].isOpen) { 160 | // Only execute the callback if the modal is closed 161 | return 162 | } 163 | 164 | stack.value[index].shouldRender = false 165 | this.afterLeaveCallback?.() 166 | this.afterLeaveCallback = null 167 | } 168 | 169 | if (index === 0) { 170 | stack.value = [] 171 | } 172 | } 173 | 174 | on = (event, callback) => { 175 | event = kebabCase(event) 176 | this.listeners[event] = this.listeners[event] ?? [] 177 | this.listeners[event].push(callback) 178 | } 179 | 180 | off = (event, callback) => { 181 | event = kebabCase(event) 182 | if (callback) { 183 | this.listeners[event] = this.listeners[event]?.filter((cb) => cb !== callback) ?? [] 184 | } else { 185 | delete this.listeners[event] 186 | } 187 | } 188 | 189 | emit = (event, ...args) => { 190 | this.listeners[kebabCase(event)]?.forEach((callback) => callback(...args)) 191 | } 192 | 193 | registerEventListenersFromAttrs = ($attrs) => { 194 | const unsubscribers = [] 195 | 196 | Object.keys($attrs) 197 | .filter((key) => key.startsWith('on')) 198 | .forEach((key) => { 199 | const eventName = kebabCase(key).replace(/^on-/, '') 200 | this.on(eventName, $attrs[key]) 201 | unsubscribers.push(() => this.off(eventName, $attrs[key])) 202 | }) 203 | 204 | return () => unsubscribers.forEach((unsub) => unsub()) 205 | } 206 | 207 | reload = (options = {}) => { 208 | let keys = Object.keys(this.response.props) 209 | 210 | if (options.only) { 211 | keys = options.only 212 | } 213 | 214 | if (options.except) { 215 | keys = except(keys, options.except) 216 | } 217 | 218 | if (!this.response?.url) { 219 | return 220 | } 221 | 222 | const method = (options.method ?? 'get').toLowerCase() 223 | const data = options.data ?? {} 224 | 225 | options.onStart?.() 226 | 227 | Axios({ 228 | url: this.response.url, 229 | method, 230 | data: method === 'get' ? {} : data, 231 | params: method === 'get' ? data : {}, 232 | headers: { 233 | ...(options.headers ?? {}), 234 | Accept: 'text/html, application/xhtml+xml', 235 | 'X-Inertia': true, 236 | 'X-Inertia-Partial-Component': this.response.component, 237 | 'X-Inertia-Version': this.response.version, 238 | 'X-Inertia-Partial-Data': keys.join(','), 239 | 'X-InertiaUI-Modal': generateId(), 240 | 'X-InertiaUI-Modal-Use-Router': 0, 241 | 'X-InertiaUI-Modal-Base-Url': baseUrl.value, 242 | }, 243 | }) 244 | .then((response) => { 245 | this.updateProps(response.data.props) 246 | options.onSuccess?.(response) 247 | }) 248 | .catch((error) => { 249 | options.onError?.(error) 250 | }) 251 | .finally(() => { 252 | options.onFinish?.() 253 | }) 254 | } 255 | 256 | updateProps = (props) => { 257 | Object.assign(this.props.value, props) 258 | } 259 | } 260 | 261 | function registerLocalModal(name, callback) { 262 | localModals.value[name] = { name, callback } 263 | } 264 | 265 | function pushLocalModal(name, config, onClose, afterLeave) { 266 | if (!localModals.value[name]) { 267 | throw new Error(`The local modal "${name}" has not been registered.`) 268 | } 269 | 270 | const modal = push(null, {}, config, onClose, afterLeave) 271 | modal.name = name 272 | localModals.value[name].callback(modal) 273 | return modal 274 | } 275 | 276 | function pushFromResponseData(responseData, config = {}, onClose = null, onAfterLeave = null) { 277 | return resolveComponent(responseData.component).then((component) => push(markRaw(component), responseData, config, onClose, onAfterLeave)) 278 | } 279 | 280 | function visit( 281 | href, 282 | method, 283 | payload = {}, 284 | headers = {}, 285 | config = {}, 286 | onClose = null, 287 | onAfterLeave = null, 288 | queryStringArrayFormat = 'brackets', 289 | useBrowserHistory = false, 290 | onStart = null, 291 | onSuccess = null, 292 | onError = null, 293 | ) { 294 | const modalId = generateId() 295 | 296 | return new Promise((resolve, reject) => { 297 | if (href.startsWith('#')) { 298 | resolve(pushLocalModal(href.substring(1), config, onClose, onAfterLeave)) 299 | return 300 | } 301 | 302 | const [url, data] = mergeDataIntoQueryString(method, href || '', payload, queryStringArrayFormat) 303 | 304 | let useInertiaRouter = useBrowserHistory && stack.value.length === 0 305 | 306 | if (stack.value.length === 0) { 307 | baseUrl.value = typeof window !== 'undefined' ? window.location.href : '' 308 | } 309 | 310 | headers = { 311 | ...headers, 312 | Accept: 'text/html, application/xhtml+xml', 313 | 'X-Requested-With': 'XMLHttpRequest', 314 | 'X-Inertia': true, 315 | 'X-Inertia-Version': usePage().version, 316 | 'X-InertiaUI-Modal': modalId, 317 | 'X-InertiaUI-Modal-Use-Router': useInertiaRouter ? 1 : 0, 318 | 'X-InertiaUI-Modal-Base-Url': baseUrl.value, 319 | } 320 | 321 | if (useInertiaRouter) { 322 | pendingModalUpdates.value[modalId] = { config, onClose, onAfterLeave } 323 | 324 | // Pushing the modal to the stack will be handled by the ModalRoot... 325 | return router.visit(url, { 326 | method, 327 | data, 328 | headers, 329 | preserveScroll: true, 330 | preserveState: true, 331 | onError(...args) { 332 | onError?.(...args) 333 | reject(...args) 334 | }, 335 | onStart(...args) { 336 | onStart?.(...args) 337 | }, 338 | onSuccess(...args) { 339 | onSuccess?.(...args) 340 | }, 341 | onBefore: () => { 342 | baseModalsToWaitFor.value[modalId] = resolve 343 | }, 344 | }) 345 | } 346 | 347 | onStart?.() 348 | 349 | const withProgress = (callback) => { 350 | try { 351 | InertiaVue.progress ? callback(InertiaVue.progress) : null 352 | } catch (e) { 353 | // ignore 354 | } 355 | } 356 | 357 | withProgress((progress) => progress.start()) 358 | 359 | Axios({ url, method, data, headers }) 360 | .then((response) => { 361 | onSuccess?.(response) 362 | resolve(pushFromResponseData(response.data, config, onClose, onAfterLeave)) 363 | }) 364 | .catch((...args) => { 365 | onError?.(...args) 366 | reject(...args) 367 | }) 368 | .finally(() => { 369 | withProgress((progress) => progress.finish()) 370 | }) 371 | }) 372 | } 373 | 374 | function loadDeferredProps(modal) { 375 | const deferred = modal.response?.meta?.deferredProps 376 | 377 | if (!deferred) { 378 | return 379 | } 380 | 381 | Object.keys(deferred).forEach((key) => { 382 | modal.reload({ only: deferred[key] }) 383 | }) 384 | } 385 | 386 | function push(component, response, config, onClose, afterLeave) { 387 | const newModal = new Modal(component, response, config, onClose, afterLeave) 388 | stack.value.push(newModal) 389 | loadDeferredProps(newModal) 390 | 391 | nextTick(() => newModal.show()) 392 | 393 | return newModal 394 | } 395 | 396 | export const modalPropNames = ['closeButton', 'closeExplicitly', 'maxWidth', 'paddingClasses', 'panelClasses', 'position', 'slideover'] 397 | 398 | export const renderApp = (App, props) => { 399 | if (props.resolveComponent) { 400 | resolveComponent = props.resolveComponent 401 | } 402 | 403 | return () => h(ModalRoot, () => h(App, props)) 404 | } 405 | 406 | export function useModalStack() { 407 | return { 408 | setComponentResolver, 409 | getBaseUrl: () => baseUrl.value, 410 | setBaseUrl: (url) => (baseUrl.value = url), 411 | stack: readonly(stack), 412 | push, 413 | pushFromResponseData, 414 | closeAll: () => [...stack.value].reverse().forEach((modal) => modal.close()), 415 | reset: () => (stack.value = []), 416 | visit, 417 | registerLocalModal, 418 | removeLocalModal: (name) => delete localModals.value[name], 419 | onModalOnBase(baseModal) { 420 | const resolve = baseModalsToWaitFor.value[baseModal.id] 421 | 422 | if (resolve) { 423 | resolve(baseModal) 424 | delete baseModalsToWaitFor.value[baseModal.id] 425 | } 426 | }, 427 | } 428 | } 429 | -------------------------------------------------------------------------------- /react/src/ModalRoot.jsx: -------------------------------------------------------------------------------- 1 | import { createElement, useEffect, useState, useRef } from 'react' 2 | import { default as Axios } from 'axios' 3 | import { except, kebabCase, generateId, sameUrlPath } from './helpers' 4 | import { router, usePage } from '@inertiajs/react' 5 | import * as InertiaReact from '@inertiajs/react' 6 | import { mergeDataIntoQueryString } from '@inertiajs/core' 7 | import { createContext, useContext } from 'react' 8 | import ModalRenderer from './ModalRenderer' 9 | import { getConfig } from './config' 10 | 11 | const ModalStackContext = createContext(null) 12 | ModalStackContext.displayName = 'ModalStackContext' 13 | 14 | let pageVersion = null 15 | let resolveComponent = null 16 | let baseUrl = null 17 | let baseModalsToWaitFor = {} 18 | let localStackCopy = [] 19 | let pendingModalUpdates = {} 20 | 21 | export const ModalStackProvider = ({ children }) => { 22 | const [stack, setStack] = useState([]) 23 | const [localModals, setLocalModals] = useState({}) 24 | 25 | const updateStack = (withStack) => { 26 | setStack((prevStack) => { 27 | const newStack = withStack([...prevStack]) 28 | 29 | const isOnTopOfStack = (modalId) => { 30 | if (newStack.length < 2) { 31 | return true 32 | } 33 | 34 | return ( 35 | newStack 36 | .map((modal) => ({ id: modal.id, shouldRender: modal.shouldRender })) 37 | .reverse() 38 | .find((modal) => modal.shouldRender)?.id === modalId 39 | ) 40 | } 41 | 42 | newStack.forEach((modal, index) => { 43 | newStack[index].onTopOfStack = isOnTopOfStack(modal.id) 44 | newStack[index].getParentModal = () => { 45 | if (index < 1) { 46 | // This is the first modal in the stack 47 | return null 48 | } 49 | 50 | // Find the first open modal before this one 51 | return newStack 52 | .slice(0, index) 53 | .reverse() 54 | .find((modal) => modal.isOpen) 55 | } 56 | newStack[index].getChildModal = () => { 57 | if (index === newStack.length - 1) { 58 | // This is the last modal in the stack 59 | return null 60 | } 61 | 62 | // Find the first open modal after this one 63 | return newStack.slice(index + 1).find((modal) => modal.isOpen) 64 | } 65 | }) 66 | 67 | return newStack 68 | }) 69 | } 70 | 71 | useEffect(() => { 72 | localStackCopy = stack 73 | }, [stack]) 74 | 75 | class Modal { 76 | constructor(component, response, config, onClose, afterLeave) { 77 | this.id = response.id ?? generateId() 78 | this.isOpen = false 79 | this.shouldRender = false 80 | this.listeners = {} 81 | 82 | this.component = component 83 | this.props = response.props 84 | this.response = response 85 | this.config = config ?? {} 86 | this.onCloseCallback = onClose 87 | this.afterLeaveCallback = afterLeave 88 | 89 | if (pendingModalUpdates[this.id]) { 90 | this.config = { 91 | ...this.config, 92 | ...(pendingModalUpdates[this.id].config ?? {}), 93 | } 94 | 95 | const pendingOnClose = pendingModalUpdates[this.id].onClose 96 | const pendingOnAfterLeave = pendingModalUpdates[this.id].onAfterLeave 97 | 98 | if (pendingOnClose) { 99 | this.onCloseCallback = onClose 100 | ? () => { 101 | onClose() 102 | pendingOnClose() 103 | } 104 | : pendingOnClose 105 | } 106 | 107 | if (pendingOnAfterLeave) { 108 | this.afterLeaveCallback = afterLeave 109 | ? () => { 110 | afterLeave() 111 | pendingOnAfterLeave() 112 | } 113 | : pendingOnAfterLeave 114 | } 115 | 116 | delete pendingModalUpdates[this.id] 117 | } 118 | 119 | this.index = -1 // Will be set when added to the stack 120 | this.getParentModal = () => null // Will be set in push() 121 | this.getChildModal = () => null // Will be set in push() 122 | this.onTopOfStack = true // Will be updated in push() 123 | } 124 | 125 | static generateId() { 126 | if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { 127 | return `inertiaui_modal_${crypto.randomUUID()}` 128 | } 129 | // Fallback for environments where crypto.randomUUID is not available 130 | return `inertiaui_modal_${Date.now().toString(36)}_${Math.random().toString(36).substr(2, 9)}` 131 | } 132 | 133 | show = () => { 134 | updateStack((prevStack) => 135 | prevStack.map((modal) => { 136 | if (modal.id === this.id && !modal.isOpen) { 137 | modal.isOpen = true 138 | modal.shouldRender = true 139 | } 140 | return modal 141 | }), 142 | ) 143 | } 144 | 145 | setOpen = (open) => { 146 | open ? this.show() : this.close() 147 | } 148 | 149 | close = () => { 150 | updateStack((currentStack) => { 151 | let modalClosed = false 152 | 153 | const newStack = currentStack.map((modal) => { 154 | if (modal.id === this.id && modal.isOpen) { 155 | Object.keys(modal.listeners).forEach((event) => { 156 | modal.off(event) 157 | }) 158 | 159 | modal.isOpen = false 160 | modal.onCloseCallback?.() 161 | modalClosed = true 162 | } 163 | return modal 164 | }) 165 | 166 | return modalClosed ? newStack : currentStack 167 | }) 168 | } 169 | 170 | afterLeave = () => { 171 | if (this.isOpen) { 172 | return 173 | } 174 | 175 | updateStack((prevStack) => { 176 | const updatedStack = prevStack.map((modal) => { 177 | if (modal.id === this.id && !modal.isOpen) { 178 | modal.shouldRender = false 179 | modal.afterLeaveCallback?.() 180 | modal.afterLeaveCallback = null 181 | } 182 | return modal 183 | }) 184 | 185 | return this.index === 0 ? [] : updatedStack 186 | }) 187 | } 188 | 189 | on = (event, callback) => { 190 | event = kebabCase(event) 191 | this.listeners[event] = this.listeners[event] ?? [] 192 | this.listeners[event].push(callback) 193 | } 194 | 195 | off = (event, callback) => { 196 | event = kebabCase(event) 197 | if (callback) { 198 | this.listeners[event] = this.listeners[event]?.filter((cb) => cb !== callback) ?? [] 199 | } else { 200 | delete this.listeners[event] 201 | } 202 | } 203 | 204 | emit = (event, ...args) => { 205 | this.listeners[kebabCase(event)]?.forEach((callback) => callback(...args)) 206 | } 207 | 208 | registerEventListenersFromProps = (props) => { 209 | const unsubscribers = [] 210 | 211 | Object.keys(props) 212 | .filter((key) => key.startsWith('on')) 213 | .forEach((key) => { 214 | // e.g. onRefreshKey -> refresh-key 215 | const eventName = kebabCase(key).replace(/^on-/, '') 216 | this.on(eventName, props[key]) 217 | unsubscribers.push(() => this.off(eventName, props[key])) 218 | }) 219 | 220 | return () => unsubscribers.forEach((unsub) => unsub()) 221 | } 222 | 223 | reload = (options = {}) => { 224 | let keys = Object.keys(this.response.props) 225 | 226 | if (options.only) { 227 | keys = options.only 228 | } 229 | 230 | if (options.except) { 231 | keys = except(keys, options.except) 232 | } 233 | 234 | if (!this.response?.url) { 235 | return 236 | } 237 | 238 | const method = (options.method ?? 'get').toLowerCase() 239 | const data = options.data ?? {} 240 | 241 | options.onStart?.() 242 | 243 | Axios({ 244 | url: this.response.url, 245 | method, 246 | data: method === 'get' ? {} : data, 247 | params: method === 'get' ? data : {}, 248 | headers: { 249 | ...(options.headers ?? {}), 250 | Accept: 'text/html, application/xhtml+xml', 251 | 'X-Inertia': true, 252 | 'X-Inertia-Partial-Component': this.response.component, 253 | 'X-Inertia-Version': this.response.version, 254 | 'X-Inertia-Partial-Data': keys.join(','), 255 | 'X-InertiaUI-Modal': generateId(), 256 | 'X-InertiaUI-Modal-Use-Router': 0, 257 | 'X-InertiaUI-Modal-Base-Url': baseUrl, 258 | }, 259 | }) 260 | .then((response) => { 261 | this.updateProps(response.data.props) 262 | 263 | options.onSuccess?.(response) 264 | }) 265 | .catch((error) => { 266 | options.onError?.(error) 267 | }) 268 | .finally(() => { 269 | options.onFinish?.() 270 | }) 271 | } 272 | 273 | updateProps = (props) => { 274 | Object.assign(this.props, props) 275 | updateStack((prevStack) => prevStack) // Trigger re-render 276 | } 277 | } 278 | 279 | const pushFromResponseData = (responseData, config = {}, onClose = null, onAfterLeave = null) => { 280 | return resolveComponent(responseData.component).then((component) => push(component, responseData, config, onClose, onAfterLeave)) 281 | } 282 | 283 | const loadDeferredProps = (modal) => { 284 | const deferred = modal.response?.meta?.deferredProps 285 | 286 | if (!deferred) { 287 | return 288 | } 289 | 290 | Object.keys(deferred).forEach((key) => { 291 | modal.reload({ only: deferred[key] }) 292 | }) 293 | } 294 | 295 | const push = (component, response, config, onClose, afterLeave) => { 296 | const newModal = new Modal(component, response, config, onClose, afterLeave) 297 | newModal.index = stack.length 298 | 299 | updateStack((prevStack) => [...prevStack, newModal]) 300 | loadDeferredProps(newModal) 301 | 302 | newModal.show() 303 | 304 | return newModal 305 | } 306 | 307 | function pushLocalModal(name, config, onClose, afterLeave) { 308 | if (!localModals[name]) { 309 | throw new Error(`The local modal "${name}" has not been registered.`) 310 | } 311 | 312 | const modal = push(null, {}, config, onClose, afterLeave) 313 | modal.name = name 314 | localModals[name].callback(modal) 315 | return modal 316 | } 317 | 318 | const visitModal = (url, options = {}) => 319 | visit( 320 | url, 321 | options.method ?? 'get', 322 | options.data ?? {}, 323 | options.headers ?? {}, 324 | options.config ?? {}, 325 | options.onClose, 326 | options.onAfterLeave, 327 | options.queryStringArrayFormat ?? 'brackets', 328 | options.navigate ?? getConfig('navigate'), 329 | options.onStart, 330 | options.onSuccess, 331 | options.onError, 332 | ).then((modal) => { 333 | const listeners = options.listeners ?? {} 334 | 335 | Object.keys(listeners).forEach((event) => { 336 | // e.g. refreshKey -> refresh-key 337 | const eventName = kebabCase(event) 338 | modal.on(eventName, listeners[event]) 339 | }) 340 | 341 | return modal 342 | }) 343 | 344 | const visit = ( 345 | href, 346 | method, 347 | payload = {}, 348 | headers = {}, 349 | config = {}, 350 | onClose = null, 351 | onAfterLeave = null, 352 | queryStringArrayFormat = 'brackets', 353 | useBrowserHistory = false, 354 | onStart = null, 355 | onSuccess = null, 356 | onError = null, 357 | ) => { 358 | const modalId = generateId() 359 | 360 | return new Promise((resolve, reject) => { 361 | if (href.startsWith('#')) { 362 | resolve(pushLocalModal(href.substring(1), config, onClose, onAfterLeave)) 363 | return 364 | } 365 | 366 | const [url, data] = mergeDataIntoQueryString(method, href || '', payload, queryStringArrayFormat) 367 | 368 | let useInertiaRouter = useBrowserHistory && stack.length === 0 369 | 370 | if (stack.length === 0) { 371 | baseUrl = typeof window !== 'undefined' ? window.location.href : '' 372 | } 373 | 374 | headers = { 375 | ...headers, 376 | Accept: 'text/html, application/xhtml+xml', 377 | 'X-Requested-With': 'XMLHttpRequest', 378 | 'X-Inertia': true, 379 | 'X-Inertia-Version': pageVersion, 380 | 'X-InertiaUI-Modal': modalId, 381 | 'X-InertiaUI-Modal-Use-Router': useInertiaRouter ? 1 : 0, 382 | 'X-InertiaUI-Modal-Base-Url': baseUrl, 383 | } 384 | 385 | if (useInertiaRouter) { 386 | baseModalsToWaitFor = {} 387 | 388 | pendingModalUpdates[modalId] = { 389 | config, 390 | onClose, 391 | onAfterLeave, 392 | } 393 | 394 | // Pushing the modal to the stack will be handled by the ModalRoot... 395 | return router.visit(url, { 396 | method, 397 | data, 398 | headers, 399 | preserveScroll: true, 400 | preserveState: true, 401 | onError(...args) { 402 | onError?.(...args) 403 | reject(...args) 404 | }, 405 | onStart(...args) { 406 | onStart?.(...args) 407 | }, 408 | onSuccess(...args) { 409 | onSuccess?.(...args) 410 | }, 411 | onBefore: () => { 412 | baseModalsToWaitFor[modalId] = resolve 413 | }, 414 | }) 415 | } 416 | 417 | onStart?.() 418 | 419 | const withProgress = (callback) => { 420 | try { 421 | InertiaReact.progress ? callback(InertiaReact.progress) : null 422 | } catch (e) { 423 | // ignore 424 | } 425 | } 426 | 427 | withProgress((progress) => progress.start()) 428 | 429 | Axios({ 430 | url, 431 | method, 432 | data, 433 | headers, 434 | }) 435 | .then((response) => { 436 | onSuccess?.(response) 437 | resolve(pushFromResponseData(response.data, config, onClose, onAfterLeave)) 438 | }) 439 | .catch((...args) => { 440 | onError?.(...args) 441 | reject(...args) 442 | }) 443 | .finally(() => { 444 | withProgress((progress) => progress.finish()) 445 | }) 446 | }) 447 | } 448 | 449 | const registerLocalModal = (name, callback) => { 450 | setLocalModals((prevLocalModals) => ({ 451 | ...prevLocalModals, 452 | [name]: { name, callback }, 453 | })) 454 | } 455 | 456 | const removeLocalModal = (name) => { 457 | setLocalModals((prevLocalModals) => { 458 | const newLocalModals = { ...prevLocalModals } 459 | delete newLocalModals[name] 460 | return newLocalModals 461 | }) 462 | } 463 | 464 | const value = { 465 | stack, 466 | localModals, 467 | push, 468 | pushFromResponseData, 469 | length: () => localStackCopy.length, 470 | closeAll: () => { 471 | localStackCopy.reverse().forEach((modal) => modal.close()) 472 | }, 473 | reset: () => updateStack(() => []), 474 | visit, 475 | visitModal, 476 | registerLocalModal, 477 | removeLocalModal, 478 | onModalOnBase: (modalOnBase) => { 479 | const resolve = baseModalsToWaitFor[modalOnBase.id] 480 | 481 | if (resolve) { 482 | resolve(modalOnBase) 483 | delete baseModalsToWaitFor[modalOnBase.id] 484 | } 485 | }, 486 | } 487 | 488 | return {children} 489 | } 490 | 491 | export const useModalStack = () => { 492 | const context = useContext(ModalStackContext) 493 | if (context === null) { 494 | throw new Error('useModalStack must be used within a ModalStackProvider') 495 | } 496 | return context 497 | } 498 | 499 | export const modalPropNames = ['closeButton', 'closeExplicitly', 'maxWidth', 'paddingClasses', 'panelClasses', 'position', 'slideover'] 500 | 501 | export const initFromPageProps = (pageProps) => { 502 | if (pageProps.initialPage) { 503 | pageVersion = pageProps.initialPage.version 504 | } 505 | 506 | if (pageProps.resolveComponent) { 507 | resolveComponent = pageProps.resolveComponent 508 | } 509 | } 510 | 511 | export const renderApp = (App, pageProps) => { 512 | initFromPageProps(pageProps) 513 | 514 | const renderInertiaApp = ({ Component, props, key }) => { 515 | const renderComponent = () => { 516 | const child = createElement(Component, { key, ...props }) 517 | 518 | if (typeof Component.layout === 'function') { 519 | return Component.layout(child) 520 | } 521 | 522 | if (Array.isArray(Component.layout)) { 523 | const layouts = Component.layout 524 | .concat(child) 525 | .reverse() 526 | .reduce((children, Layout) => createElement(Layout, props, children)) 527 | 528 | return layouts 529 | } 530 | 531 | return child 532 | } 533 | 534 | return ( 535 | <> 536 | {renderComponent()} 537 | 538 | 539 | ) 540 | } 541 | 542 | return ( 543 | 544 | {renderInertiaApp} 545 | 546 | ) 547 | } 548 | 549 | export const ModalRoot = ({ children }) => { 550 | const context = useContext(ModalStackContext) 551 | const $page = usePage() 552 | 553 | let isNavigating = false 554 | let initialModalStillOpened = $page.props?._inertiaui_modal ? true : false 555 | 556 | useEffect(() => router.on('start', () => (isNavigating = true)), []) 557 | useEffect(() => router.on('finish', () => (isNavigating = false)), []) 558 | useEffect( 559 | () => 560 | router.on('navigate', function ($event) { 561 | const modalOnBase = $event.detail.page.props._inertiaui_modal 562 | 563 | if (!modalOnBase) { 564 | context.closeAll() 565 | baseUrl = null 566 | initialModalStillOpened = false 567 | return 568 | } 569 | 570 | baseUrl = modalOnBase.baseUrl 571 | 572 | context 573 | .pushFromResponseData(modalOnBase, {}, () => { 574 | if (!modalOnBase.baseUrl) { 575 | console.error('No base url in modal response data so cannot navigate back') 576 | return 577 | } 578 | if (!isNavigating && typeof window !== 'undefined' && window.location.href !== modalOnBase.baseUrl) { 579 | router.visit(modalOnBase.baseUrl, { 580 | preserveScroll: true, 581 | preserveState: true, 582 | }) 583 | } 584 | }) 585 | .then(context.onModalOnBase) 586 | }), 587 | [], 588 | ) 589 | 590 | const axiosRequestInterceptor = (config) => { 591 | // A Modal is opened on top of a base route, so we need to pass this base route 592 | // so it can redirect back with the back() helper method... 593 | config.headers['X-InertiaUI-Modal-Base-Url'] = baseUrl ?? (initialModalStillOpened ? $page.props._inertiaui_modal?.baseUrl : null) 594 | 595 | return config 596 | } 597 | 598 | useEffect(() => { 599 | Axios.interceptors.request.use(axiosRequestInterceptor) 600 | return () => Axios.interceptors.request.eject(axiosRequestInterceptor) 601 | }, []) 602 | 603 | const previousModalRef = useRef() 604 | 605 | useEffect(() => { 606 | const newModal = $page.props?._inertiaui_modal 607 | const previousModal = previousModalRef.current 608 | 609 | // Store the current value for the next render 610 | previousModalRef.current = newModal 611 | 612 | if (newModal && previousModal && newModal.component === previousModal.component && sameUrlPath(newModal.url, previousModal.url)) { 613 | context.stack[0]?.updateProps(newModal.props ?? {}) 614 | } 615 | }, [$page.props?._inertiaui_modal]) 616 | 617 | return ( 618 | <> 619 | {children} 620 | {context.stack.length > 0 && } 621 | 622 | ) 623 | } 624 | --------------------------------------------------------------------------------