@inertiaui/modal-react 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,21 @@
1
- import { TransitionChild, DialogPanel } from '@headlessui/react'
1
+ import { TransitionChild } from '@headlessui/react'
2
2
  import CloseButton from './CloseButton'
3
3
  import clsx from 'clsx'
4
+ import { focusTrapper } from './focusTrapper'
5
+ import { useEffect, useRef, useState } from 'react'
4
6
 
5
7
  const ModalContent = ({ modalContext, config, children }) => {
8
+ const [entered, setEntered] = useState(false)
9
+ const wrapper = useRef(null)
10
+ const [focusTrap, setFocusTrap] = useState(null)
11
+
12
+ function afterEnter() {
13
+ setFocusTrap(focusTrapper(wrapper.current, config?.closeExplicitly, () => modalContext.close()))
14
+ setEntered(true)
15
+ }
16
+
17
+ useEffect(() => () => focusTrap?.deactivate(), [focusTrap])
18
+
6
19
  return (
7
20
  <div className="im-modal-container fixed inset-0 z-40 overflow-y-auto p-4">
8
21
  <div
@@ -13,32 +26,42 @@ const ModalContent = ({ modalContext, config, children }) => {
13
26
  })}
14
27
  >
15
28
  <TransitionChild
29
+ as="div"
30
+ ref={wrapper}
16
31
  enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
17
32
  enterTo="opacity-100 translate-y-0 sm:scale-100"
18
33
  leaveFrom="opacity-100 translate-y-0 sm:scale-100"
19
34
  leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
35
+ afterEnter={afterEnter}
20
36
  afterLeave={modalContext.afterLeave}
21
- className={clsx('im-modal-wrapper w-full transition duration-300 ease-in-out', modalContext.onTopOfStack ? '' : 'blur-sm', {
22
- 'sm:max-w-sm': config.maxWidth === 'sm',
23
- 'sm:max-w-md': config.maxWidth === 'md',
24
- 'sm:max-w-md md:max-w-lg': config.maxWidth === 'lg',
25
- 'sm:max-w-md md:max-w-xl': config.maxWidth === 'xl',
26
- 'sm:max-w-md md:max-w-xl lg:max-w-2xl': config.maxWidth === '2xl',
27
- 'sm:max-w-md md:max-w-xl lg:max-w-3xl': config.maxWidth === '3xl',
28
- 'sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-4xl': config.maxWidth === '4xl',
29
- 'sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-5xl': config.maxWidth === '5xl',
30
- 'sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-5xl 2xl:max-w-6xl': config.maxWidth === '6xl',
31
- 'sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-5xl 2xl:max-w-7xl': config.maxWidth === '7xl',
32
- })}
37
+ className={clsx(
38
+ 'im-modal-wrapper pointer-events-auto w-full transition duration-300 ease-in-out',
39
+ modalContext.onTopOfStack ? '' : 'blur-sm',
40
+ {
41
+ 'sm:max-w-sm': config.maxWidth === 'sm',
42
+ 'sm:max-w-md': config.maxWidth === 'md',
43
+ 'sm:max-w-md md:max-w-lg': config.maxWidth === 'lg',
44
+ 'sm:max-w-md md:max-w-xl': config.maxWidth === 'xl',
45
+ 'sm:max-w-md md:max-w-xl lg:max-w-2xl': config.maxWidth === '2xl',
46
+ 'sm:max-w-md md:max-w-xl lg:max-w-3xl': config.maxWidth === '3xl',
47
+ 'sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-4xl': config.maxWidth === '4xl',
48
+ 'sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-5xl': config.maxWidth === '5xl',
49
+ 'sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-5xl 2xl:max-w-6xl': config.maxWidth === '6xl',
50
+ 'sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-5xl 2xl:max-w-7xl': config.maxWidth === '7xl',
51
+ },
52
+ )}
33
53
  >
34
- <DialogPanel className={`im-modal-content relative ${config.paddingClasses} ${config.panelClasses}`}>
54
+ <div
55
+ className={`im-modal-content relative ${config.paddingClasses} ${config.panelClasses}`}
56
+ data-inertiaui-modal-entered={entered}
57
+ >
35
58
  {config.closeButton && (
36
59
  <div className="absolute right-0 top-0 pr-3 pt-3">
37
60
  <CloseButton onClick={modalContext.close} />
38
61
  </div>
39
62
  )}
40
63
  {typeof children === 'function' ? children({ modalContext, config }) : children}
41
- </DialogPanel>
64
+ </div>
42
65
  </TransitionChild>
43
66
  </div>
44
67
  </div>
package/src/ModalRoot.jsx CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createElement, useEffect, useState, useRef } from 'react'
2
2
  import { default as Axios } from 'axios'
3
- import { except, only, kebabCase } from './helpers'
3
+ import { except, only, kebabCase, generateId } from './helpers'
4
4
  import { router, usePage } from '@inertiajs/react'
5
5
  import { mergeDataIntoQueryString } from '@inertiajs/core'
6
6
  import { createContext, useContext } from 'react'
@@ -16,6 +16,7 @@ let resolveComponent = null
16
16
  let baseUrl = null
17
17
  let newModalOnBase = null
18
18
  let localStackCopy = []
19
+ let pendingModalUpdates = {}
19
20
 
20
21
  export const ModalStackProvider = ({ children }) => {
21
22
  const [stack, setStack] = useState([])
@@ -73,7 +74,7 @@ export const ModalStackProvider = ({ children }) => {
73
74
 
74
75
  class Modal {
75
76
  constructor(component, response, config, onClose, afterLeave) {
76
- this.id = Modal.generateId()
77
+ this.id = response.id ?? generateId()
77
78
  this.isOpen = false
78
79
  this.shouldRender = false
79
80
  this.listeners = {}
@@ -81,10 +82,40 @@ export const ModalStackProvider = ({ children }) => {
81
82
  this.component = component
82
83
  this.props = response.props
83
84
  this.response = response
84
- this.config = config
85
+ this.config = config ?? {}
85
86
  this.onCloseCallback = onClose
86
87
  this.afterLeaveCallback = afterLeave
87
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
+
88
119
  this.index = -1 // Will be set when added to the stack
89
120
  this.getParentModal = () => null // Will be set in push()
90
121
  this.getChildModal = () => null // Will be set in push()
@@ -99,19 +130,6 @@ export const ModalStackProvider = ({ children }) => {
99
130
  return `inertiaui_modal_${Date.now().toString(36)}_${Math.random().toString(36).substr(2, 9)}`
100
131
  }
101
132
 
102
- update = (config, onClose, afterLeave) => {
103
- updateStack((prevStack) =>
104
- prevStack.map((modal) => {
105
- if (modal.id === this.id) {
106
- modal.config = config
107
- modal.onCloseCallback = onClose
108
- modal.afterLeaveCallback = afterLeave
109
- }
110
- return modal
111
- }),
112
- )
113
- }
114
-
115
133
  show = () => {
116
134
  updateStack((prevStack) =>
117
135
  prevStack.map((modal) => {
@@ -219,7 +237,7 @@ export const ModalStackProvider = ({ children }) => {
219
237
  'X-Inertia-Partial-Component': this.response.component,
220
238
  'X-Inertia-Version': this.response.version,
221
239
  'X-Inertia-Partial-Data': keys.join(','),
222
- 'X-InertiaUI-Modal': true,
240
+ 'X-InertiaUI-Modal': generateId(),
223
241
  'X-InertiaUI-Modal-Use-Router': 0,
224
242
  'X-InertiaUI-Modal-Base-Url': baseUrl,
225
243
  },
@@ -294,6 +312,8 @@ export const ModalStackProvider = ({ children }) => {
294
312
  queryStringArrayFormat = 'brackets',
295
313
  useBrowserHistory = false,
296
314
  ) => {
315
+ const modalId = generateId()
316
+
297
317
  return new Promise((resolve, reject) => {
298
318
  if (href.startsWith('#')) {
299
319
  resolve(pushLocalModal(href.substring(1), config, onClose, onAfterLeave))
@@ -314,13 +334,20 @@ export const ModalStackProvider = ({ children }) => {
314
334
  'X-Requested-With': 'XMLHttpRequest',
315
335
  'X-Inertia': true,
316
336
  'X-Inertia-Version': pageVersion,
317
- 'X-InertiaUI-Modal': true,
337
+ 'X-InertiaUI-Modal': modalId,
318
338
  'X-InertiaUI-Modal-Use-Router': useInertiaRouter ? 1 : 0,
319
339
  'X-InertiaUI-Modal-Base-Url': baseUrl,
320
340
  }
321
341
 
322
342
  if (useInertiaRouter) {
323
343
  newModalOnBase = null
344
+
345
+ pendingModalUpdates[modalId] = {
346
+ config,
347
+ onClose,
348
+ onAfterLeave,
349
+ }
350
+
324
351
  // Pushing the modal to the stack will be handled by the ModalRoot...
325
352
  return router.visit(url, {
326
353
  method,
@@ -330,24 +357,7 @@ export const ModalStackProvider = ({ children }) => {
330
357
  preserveState: true,
331
358
  onError: reject,
332
359
  onFinish: () => {
333
- waitFor(() => newModalOnBase).then((modal) => {
334
- const originalOnClose = modal.onCloseCallback
335
- const originalAfterLeave = modal.afterLeaveCallback
336
-
337
- modal.update(
338
- config,
339
- () => {
340
- onClose?.()
341
- originalOnClose?.()
342
- },
343
- () => {
344
- onAfterLeave?.()
345
- originalAfterLeave?.()
346
- },
347
- )
348
-
349
- resolve(modal)
350
- })
360
+ waitFor(() => newModalOnBase).then(resolve)
351
361
  },
352
362
  })
353
363
  }
@@ -1,43 +1,66 @@
1
- import { TransitionChild, DialogPanel } from '@headlessui/react'
1
+ import { TransitionChild } from '@headlessui/react'
2
2
  import CloseButton from './CloseButton'
3
3
  import clsx from 'clsx'
4
+ import { focusTrapper } from './focusTrapper'
5
+ import { useEffect, useRef, useState } from 'react'
4
6
 
5
7
  const SlideoverContent = ({ modalContext, config, children }) => {
8
+ const [entered, setEntered] = useState(false)
9
+ const wrapper = useRef(null)
10
+ const [focusTrap, setFocusTrap] = useState(null)
11
+
12
+ function afterEnter() {
13
+ setFocusTrap(focusTrapper(wrapper.current, config?.closeExplicitly, () => modalContext.close()))
14
+ setEntered(true)
15
+ }
16
+
17
+ useEffect(() => () => focusTrap?.deactivate(), [focusTrap])
18
+
6
19
  return (
7
20
  <div className="im-slideover-container fixed inset-0 z-40 overflow-y-auto overflow-x-hidden">
8
21
  <div
9
22
  className={clsx('im-slideover-positioner flex min-h-full items-center', {
10
- 'justify-start': config.position === 'left',
11
- 'justify-end': config.position === 'right',
23
+ 'justify-start rtl:justify-end': config?.position === 'left',
24
+ 'justify-end rtl:justify-start': config?.position === 'right',
12
25
  })}
13
26
  >
14
27
  <TransitionChild
28
+ as="div"
29
+ ref={wrapper}
15
30
  enterFrom={`opacity-0 ${config.position === 'left' ? '-translate-x-full' : 'translate-x-full'}`}
16
31
  enterTo="opacity-100 translate-x-0"
17
32
  leaveFrom="opacity-100 translate-x-0"
18
33
  leaveTo={`opacity-0 ${config.position === 'left' ? '-translate-x-full' : 'translate-x-full'}`}
34
+ afterEnter={afterEnter}
19
35
  afterLeave={modalContext.afterLeave}
20
- className={clsx('im-slideover-wrapper w-full transition duration-300 ease-in-out', modalContext.onTopOfStack ? '' : 'blur-sm', {
21
- 'sm:max-w-sm': config.maxWidth === 'sm',
22
- 'sm:max-w-md': config.maxWidth === 'md',
23
- 'sm:max-w-md md:max-w-lg': config.maxWidth === 'lg',
24
- 'sm:max-w-md md:max-w-xl': config.maxWidth === 'xl',
25
- 'sm:max-w-md md:max-w-xl lg:max-w-2xl': config.maxWidth === '2xl',
26
- 'sm:max-w-md md:max-w-xl lg:max-w-3xl': config.maxWidth === '3xl',
27
- 'sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-4xl': config.maxWidth === '4xl',
28
- 'sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-5xl': config.maxWidth === '5xl',
29
- 'sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-5xl 2xl:max-w-6xl': config.maxWidth === '6xl',
30
- 'sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-5xl 2xl:max-w-7xl': config.maxWidth === '7xl',
31
- })}
36
+ className={clsx(
37
+ 'im-slideover-wrapper pointer-events-auto w-full transition duration-300 ease-in-out',
38
+ modalContext.onTopOfStack ? '' : 'blur-sm',
39
+ {
40
+ 'sm:max-w-sm': config.maxWidth === 'sm',
41
+ 'sm:max-w-md': config.maxWidth === 'md',
42
+ 'sm:max-w-md md:max-w-lg': config.maxWidth === 'lg',
43
+ 'sm:max-w-md md:max-w-xl': config.maxWidth === 'xl',
44
+ 'sm:max-w-md md:max-w-xl lg:max-w-2xl': config.maxWidth === '2xl',
45
+ 'sm:max-w-md md:max-w-xl lg:max-w-3xl': config.maxWidth === '3xl',
46
+ 'sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-4xl': config.maxWidth === '4xl',
47
+ 'sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-5xl': config.maxWidth === '5xl',
48
+ 'sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-5xl 2xl:max-w-6xl': config.maxWidth === '6xl',
49
+ 'sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-5xl 2xl:max-w-7xl': config.maxWidth === '7xl',
50
+ },
51
+ )}
32
52
  >
33
- <DialogPanel className={`im-slideover-content relative ${config.paddingClasses} ${config.panelClasses}`}>
53
+ <div
54
+ className={`im-slideover-content relative ${config.paddingClasses} ${config.panelClasses}`}
55
+ data-inertiaui-modal-entered={entered}
56
+ >
34
57
  {config.closeButton && (
35
58
  <div className="absolute right-0 top-0 pr-3 pt-3">
36
59
  <CloseButton onClick={modalContext.close} />
37
60
  </div>
38
61
  )}
39
62
  {typeof children === 'function' ? children({ modalContext, config }) : children}
40
- </DialogPanel>
63
+ </div>
41
64
  </TransitionChild>
42
65
  </div>
43
66
  </div>
@@ -0,0 +1,23 @@
1
+ import { createFocusTrap } from 'focus-trap'
2
+
3
+ export function focusTrapper(wrapper, closeExplicitly, onDeactivateCallback) {
4
+ let trap = null
5
+
6
+ if (wrapper) {
7
+ trap = createFocusTrap(wrapper, {
8
+ clickOutsideDeactivates: !closeExplicitly,
9
+ escapeDeactivates: !closeExplicitly,
10
+ onDeactivate: () => onDeactivateCallback?.(),
11
+ fallbackFocus: () => wrapper,
12
+ })
13
+
14
+ trap.activate()
15
+ }
16
+
17
+ const deactivate = () => {
18
+ trap?.deactivate()
19
+ trap = null
20
+ }
21
+
22
+ return { deactivate, wrapper }
23
+ }
package/src/helpers.js CHANGED
@@ -1,2 +1,2 @@
1
- import { except, only, rejectNullValues, waitFor, kebabCase } from './../../vue/src/helpers.js'
2
- export { except, only, rejectNullValues, waitFor, kebabCase }
1
+ import { modalDOMHandler, generateId, except, only, rejectNullValues, waitFor, kebabCase } from './../../vue/src/helpers.js'
2
+ export { modalDOMHandler, generateId, except, only, rejectNullValues, waitFor, kebabCase }