├── 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 |
6 |
7 | Close
8 |
23 |
24 |
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 |
23 | modalContext.emit(event, ...args)"
27 | />
28 |
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 |
26 |
27 |
28 |
32 |
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 |
82 |
86 |
87 |
91 |
92 |
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 |
71 |
72 |
73 |
77 |
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 |
15 |
16 |
17 |
18 |
26 |
35 | config?.closeExplicitly && $event.preventDefault()"
52 | @interact-outside="($event) => config?.closeExplicitly && $event.preventDefault()"
53 | >
54 |
55 |
56 |
57 |
58 |
63 |
67 |
68 |
69 |
70 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
--------------------------------------------------------------------------------
/vue/src/SlideoverContent.vue:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
16 |
17 |
18 |
25 |
34 | config?.closeExplicitly && $event.preventDefault()"
51 | @interact-outside="($event) => config?.closeExplicitly && $event.preventDefault()"
52 | >
53 |
54 |
55 |
56 |
57 |
62 |
66 |
67 |
68 |
69 |
73 |
74 |
75 |
76 |
77 |
78 |
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 |
161 |
167 |
168 |
169 |
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 | [](https://github.com/inertiaui/modal/actions/workflows/tests.yml)
4 | 
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 |
67 |
90 |
94 |
95 |
101 |
112 |
113 |
114 |
115 |
116 |
120 |
121 |
122 |
128 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
--------------------------------------------------------------------------------
/vue/src/HeadlessModal.vue:
--------------------------------------------------------------------------------
1 |
153 |
154 |
155 |
172 |
173 |
174 |
178 |
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 |
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 |
--------------------------------------------------------------------------------