Skip to content

Commit 71cadab

Browse files
Add generic modal component and use for identity confirmation (#118)
1 parent e733bea commit 71cadab

File tree

30 files changed

+1023
-110
lines changed

30 files changed

+1023
-110
lines changed

app/assets/javascript/main.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ document.addEventListener('DOMContentLoaded', () => {
3838
</strong>
3939
`
4040
}
41+
42+
// Close any open modal (for modal-based check-ins)
43+
const openModal = document.querySelector('.app-modal:not([hidden])')
44+
if (openModal && window.closeModal) {
45+
window.closeModal(openModal.id)
46+
}
47+
4148
} catch (error) {
4249
console.error('Error checking in participant:', error)
4350
window.location.href = link.href

app/assets/javascript/mammogram-viewer.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ window.MammogramViewer = MammogramViewer;
269269
// Set the navigating flag on popstate events
270270
window.addEventListener('popstate', function() {
271271
isNavigating = true;
272-
console.log("Navigation detected, setting isNavigating flag");
272+
// console.log("Navigation detected, setting isNavigating flag");
273273

274274
// If we're leaving reading context, close the viewer
275275
if (window.inReadingContext && !window.location.pathname.includes('/reading/batch/')) {

app/assets/javascript/modal.js

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
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+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
@use 'nhsuk-frontend/dist/nhsuk/core/settings' as *;
2+
@use 'nhsuk-frontend/dist/nhsuk/core/tools' as *;
3+
4+
$app-modal-width: 700px !default; // Default width for the modal
5+
6+
// Prevent background scrolling when modal is open
7+
body.app-modal-open {
8+
overflow: hidden;
9+
width: 100%;
10+
}
11+
12+
.app-modal {
13+
&[hidden] {
14+
display: none;
15+
}
16+
17+
&.app-modal--open {
18+
display: block;
19+
}
20+
}
21+
22+
// Modal overlay
23+
.app-modal__overlay {
24+
position: fixed;
25+
z-index: 1001;
26+
top: 0;
27+
left: 0;
28+
width: 100%;
29+
height: 100%;
30+
background-color: rgba($color_nhsuk-black, 0.8);
31+
}
32+
33+
// Modal dialog container
34+
.app-modal__dialog {
35+
position: fixed;
36+
z-index: 1002;
37+
top: 50%;
38+
left: 50%;
39+
width: calc(100vw - #{nhsuk-spacing(4)});
40+
max-width: $app-modal-width;
41+
max-height: 90vh;
42+
43+
transform: translate(-50%, -50%);
44+
overflow-y: auto;
45+
46+
// background-color: $color_nhsuk-white;
47+
background-color: $color_nhsuk-grey-5;
48+
border: $nhsuk-focus-width solid $color_nhsuk-black;
49+
50+
@include nhsuk-responsive-padding(5);
51+
52+
&:focus {
53+
outline: $nhsuk-focus-width solid $nhsuk-focus-color;
54+
}
55+
56+
@include nhsuk-media-query($from: tablet) {
57+
width: $app-modal-width;
58+
}
59+
}
60+
61+
// Modal content
62+
.app-modal__title {
63+
margin-top: 0;
64+
}
65+
66+
.app-modal__body {
67+
margin-bottom: nhsuk-spacing(4);
68+
69+
// Remove bottom margin for the last child to avoid extra space
70+
&:last-child {
71+
margin-bottom: 0;
72+
}
73+
}
74+
75+
// Modal actions
76+
.app-modal__actions {
77+
margin-top: nhsuk-spacing(4);
78+
margin-bottom: 0;
79+
80+
.nhsuk-button:last-of-type {
81+
margin-bottom: 0;
82+
}
83+
}

app/assets/sass/components/_status.scss

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,8 @@
77
top: 10px;
88
right: 0px;
99
text-align: right;
10+
.app-modal {
11+
text-align: initial;
12+
}
1013
}
1114

app/assets/sass/main.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
@forward 'components/button-menu';
66
@forward 'components/dark-mode';
77
@forward 'components/list-border';
8+
@forward 'components/modal';
89
@forward 'components/related-nav';
910
@forward 'components/secondary-navigation';
1011
@forward 'components/count';

0 commit comments

Comments
 (0)