1+ class AppModal {
2+ constructor ( element ) {
3+ this . modal = element
4+ this . dialog = this . modal . querySelector ( '.app-modal__dialog' )
5+ this . overlay = this . modal . querySelector ( '.app-modal__overlay' )
6+ this . previousActiveElement = null
7+ this . scrollPosition = 0
8+ this . isOpen = false
9+
10+ this . bindEvents ( )
11+ }
12+
13+ bindEvents ( ) {
14+ // Close on overlay click
15+ if ( this . overlay ) {
16+ this . overlay . addEventListener ( 'click' , ( ) => this . close ( ) )
17+ }
18+
19+ // Close on escape key
20+ document . addEventListener ( 'keydown' , ( e ) => {
21+ if ( e . key === 'Escape' && this . isOpen ) {
22+ this . close ( )
23+ }
24+ } )
25+
26+ // Handle action buttons and links
27+ this . modal . addEventListener ( 'click' , ( e ) => {
28+ // Find the element with data-modal-action (might be the target or a parent)
29+ let actionElement = e . target
30+ let action = actionElement . getAttribute ( 'data-modal-action' )
31+
32+ // If target doesn't have action, check if it's inside an element that does
33+ if ( ! action && actionElement . closest ( '[data-modal-action]' ) ) {
34+ actionElement = actionElement . closest ( '[data-modal-action]' )
35+ action = actionElement . getAttribute ( 'data-modal-action' )
36+ }
37+
38+ if ( action ) {
39+ console . log ( 'Modal action triggered:' , action , actionElement ) // Debug log
40+ this . handleAction ( action , e , actionElement )
41+ }
42+ } )
43+ }
44+
45+ open ( ) {
46+ console . log ( 'Opening modal:' , this . modal . id ) // Debug log
47+
48+ // Store current scroll position
49+ this . scrollPosition = window . pageYOffset || document . documentElement . scrollTop
50+
51+ this . previousActiveElement = document . activeElement
52+ this . modal . hidden = false
53+ this . modal . classList . add ( 'app-modal--open' )
54+
55+ // Prevent body scrolling and maintain scroll position
56+ document . body . classList . add ( 'app-modal-open' )
57+ document . body . style . top = `-${ this . scrollPosition } px`
58+ document . body . style . position = 'fixed'
59+ document . body . style . width = '100%'
60+
61+ // Focus the dialog
62+ this . dialog . focus ( )
63+ this . isOpen = true
64+
65+ // Trap focus within modal
66+ this . trapFocus ( )
67+ }
68+
69+ close ( ) {
70+ console . log ( 'Closing modal:' , this . modal . id ) // Debug log
71+
72+ this . modal . hidden = true
73+ this . modal . classList . remove ( 'app-modal--open' )
74+
75+ // Restore body scrolling and scroll position
76+ document . body . classList . remove ( 'app-modal-open' )
77+ document . body . style . position = ''
78+ document . body . style . top = ''
79+ document . body . style . width = ''
80+
81+ // Restore scroll position
82+ window . scrollTo ( 0 , this . scrollPosition )
83+
84+ // Restore focus
85+ if ( this . previousActiveElement ) {
86+ this . previousActiveElement . focus ( )
87+ }
88+
89+ this . isOpen = false
90+ }
91+
92+ handleAction ( action , event , actionElement ) {
93+ console . log ( 'Handling action:' , action ) // Debug log
94+
95+ switch ( action ) {
96+ case 'close' :
97+ event . preventDefault ( )
98+ this . close ( )
99+ break
100+
101+ case 'navigate' :
102+ // Let default behavior happen for links
103+ if ( actionElement . tagName === 'A' ) {
104+ // Handle POST navigation if needed
105+ const method = actionElement . getAttribute ( 'data-method' )
106+ if ( method && method . toUpperCase ( ) === 'POST' ) {
107+ event . preventDefault ( )
108+ this . submitForm ( actionElement . href , 'POST' )
109+ }
110+ }
111+ break
112+
113+ case 'ajax' :
114+ event . preventDefault ( )
115+ this . handleAjax ( actionElement )
116+ break
117+
118+ default :
119+ // Fire custom event for other action types
120+ const customEvent = new CustomEvent ( 'modal:action' , {
121+ detail : {
122+ action,
123+ target : actionElement ,
124+ originalEvent : event ,
125+ modal : this
126+ }
127+ } )
128+ this . modal . dispatchEvent ( customEvent )
129+ }
130+ }
131+
132+ handleAjax ( target ) {
133+ const href = target . getAttribute ( 'data-href' )
134+ const method = target . getAttribute ( 'data-method' ) || 'GET'
135+ const closeOnSuccess = target . getAttribute ( 'data-close-on-success' ) === 'true'
136+
137+ if ( ! href ) return
138+
139+ // Show loading state
140+ this . setButtonLoading ( target , true )
141+
142+ // Collect any data from modal data attributes
143+ const modalData = this . getModalData ( )
144+
145+ // Make AJAX request
146+ fetch ( href , {
147+ method : method ,
148+ headers : {
149+ 'Content-Type' : 'application/json' ,
150+ 'X-Requested-With' : 'XMLHttpRequest'
151+ } ,
152+ body : method !== 'GET' ? JSON . stringify ( modalData ) : null
153+ } )
154+ . then ( response => {
155+ if ( response . ok ) {
156+ if ( closeOnSuccess ) {
157+ this . close ( )
158+ }
159+ // Fire success event
160+ const successEvent = new CustomEvent ( 'modal:ajax:success' , {
161+ detail : { response, target, modal : this }
162+ } )
163+ this . modal . dispatchEvent ( successEvent )
164+ } else {
165+ throw new Error ( 'Request failed' )
166+ }
167+ } )
168+ . catch ( error => {
169+ // Fire error event
170+ const errorEvent = new CustomEvent ( 'modal:ajax:error' , {
171+ detail : { error, target, modal : this }
172+ } )
173+ this . modal . dispatchEvent ( errorEvent )
174+ } )
175+ . finally ( ( ) => {
176+ this . setButtonLoading ( target , false )
177+ } )
178+ }
179+
180+ setButtonLoading ( button , isLoading ) {
181+ if ( isLoading ) {
182+ button . disabled = true
183+ button . textContent = button . textContent + ' ...'
184+ } else {
185+ button . disabled = false
186+ button . textContent = button . textContent . replace ( ' ...' , '' )
187+ }
188+ }
189+
190+ getModalData ( ) {
191+ const data = { }
192+ const attributes = this . modal . dataset
193+
194+ // Copy all data attributes
195+ Object . keys ( attributes ) . forEach ( key => {
196+ data [ key ] = attributes [ key ]
197+ } )
198+
199+ return data
200+ }
201+
202+ submitForm ( url , method ) {
203+ const form = document . createElement ( 'form' )
204+ form . method = method
205+ form . action = url
206+ document . body . appendChild ( form )
207+ form . submit ( )
208+ }
209+
210+ trapFocus ( ) {
211+ const focusableElements = this . dialog . querySelectorAll (
212+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
213+ )
214+
215+ if ( focusableElements . length === 0 ) return
216+
217+ const firstElement = focusableElements [ 0 ]
218+ const lastElement = focusableElements [ focusableElements . length - 1 ]
219+
220+ this . dialog . addEventListener ( 'keydown' , ( e ) => {
221+ if ( e . key === 'Tab' ) {
222+ if ( e . shiftKey ) {
223+ if ( document . activeElement === firstElement ) {
224+ e . preventDefault ( )
225+ lastElement . focus ( )
226+ }
227+ } else {
228+ if ( document . activeElement === lastElement ) {
229+ e . preventDefault ( )
230+ firstElement . focus ( )
231+ }
232+ }
233+ }
234+ } )
235+ }
236+ }
237+
238+ // Initialize modals
239+ document . addEventListener ( 'DOMContentLoaded' , ( ) => {
240+ console . log ( 'Initializing modals...' ) // Debug log
241+ const modals = document . querySelectorAll ( '.app-modal' )
242+ console . log ( 'Found modals:' , modals . length ) // Debug log
243+ modals . forEach ( modal => {
244+ modal . appModal = new AppModal ( modal )
245+ } )
246+ } )
247+
248+ // Global functions
249+ window . openModal = function ( modalId ) {
250+ console . log ( 'Opening modal via global function:' , modalId ) // Debug log
251+ const modal = document . getElementById ( modalId )
252+ if ( modal && modal . appModal ) {
253+ modal . appModal . open ( )
254+ } else {
255+ console . error ( 'Modal not found or not initialized:' , modalId )
256+ }
257+ }
258+
259+ window . closeModal = function ( modalId ) {
260+ const modal = document . getElementById ( modalId )
261+ if ( modal && modal . appModal ) {
262+ modal . appModal . close ( )
263+ }
264+ }
0 commit comments