Skip to content

Commit 66a5df3

Browse files
committed
wip: local file size validation
1 parent 74d5bb8 commit 66a5df3

File tree

5 files changed

+643
-1
lines changed

5 files changed

+643
-1
lines changed

app/components/question/file_component/view.html.erb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@
77
text: question.hint_text,
88
class: "govuk-!-margin-bottom-7"
99
},
10-
accept: Question::File::FILE_TYPES.join(", ")
10+
accept: Question::File::FILE_TYPES.join(", "),
11+
data: { max_file_size: Question::File::FILE_UPLOAD_MAX_SIZE_IN_MB * 1024 * 1024 }
1112
%>

app/frontend/entrypoints/application.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from '../javascript/utils/google-analytics'
1515
import { CookieBanner } from '../../components/cookie_banner_component/cookie-banner'
1616
import { CookiePage } from '../../components/cookie_consent_form_component/cookie-consent-form'
17+
import { initFileValidation } from '../javascript/utils/file-validation'
1718

1819
const analyticsConsentStatus = loadConsentStatus()
1920

@@ -52,5 +53,6 @@ if (document.body.dataset.googleAnalyticsEnabled === 'true') {
5253
}
5354

5455
initAll()
56+
initFileValidation()
5557

5658
window.dfeAutocomplete = dfeAutocomplete
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
/**
2+
* Client-side file validation for file upload inputs
3+
* Validates file size before form submission to provide immediate feedback
4+
*/
5+
6+
const BYTES_IN_MB = 1024 * 1024
7+
8+
/**
9+
* Format bytes to a human-readable size
10+
* @param {number} bytes - The number of bytes
11+
* @returns {string} Formatted file size
12+
*/
13+
function formatFileSize(bytes) {
14+
if (bytes === 0) return '0 Bytes'
15+
16+
const mb = bytes / BYTES_IN_MB
17+
if (mb >= 1) {
18+
return `${mb.toFixed(1)} MB`
19+
}
20+
21+
const kb = bytes / 1024
22+
return `${kb.toFixed(1)} KB`
23+
}
24+
25+
/**
26+
* Show error message using GOV.UK Design System error pattern
27+
* @param {HTMLInputElement} input - The file input element
28+
* @param {string} errorMessage - The error message to display
29+
*/
30+
function showError(input, errorMessage) {
31+
const formGroup = input.closest('.govuk-form-group')
32+
if (!formGroup) return
33+
34+
// Add error class to form group
35+
formGroup.classList.add('govuk-form-group--error')
36+
37+
// Check if error message already exists
38+
let errorSpan = formGroup.querySelector('.govuk-error-message')
39+
40+
if (!errorSpan) {
41+
// Create error message element
42+
errorSpan = document.createElement('span')
43+
errorSpan.className = 'govuk-error-message'
44+
errorSpan.id = `${input.id}-error`
45+
46+
const visuallyHiddenSpan = document.createElement('span')
47+
visuallyHiddenSpan.className = 'govuk-visually-hidden'
48+
visuallyHiddenSpan.textContent = 'Error: '
49+
50+
errorSpan.appendChild(visuallyHiddenSpan)
51+
52+
// Insert error message before the file input (or after hint if present)
53+
const hint = formGroup.querySelector('.govuk-hint')
54+
const insertBefore = hint || input
55+
insertBefore.parentNode.insertBefore(errorSpan, insertBefore)
56+
}
57+
58+
// Update error message text (preserving the visually-hidden span)
59+
const visuallyHidden = errorSpan.querySelector('.govuk-visually-hidden')
60+
errorSpan.textContent = errorMessage
61+
if (visuallyHidden) {
62+
errorSpan.insertBefore(visuallyHidden, errorSpan.firstChild)
63+
}
64+
65+
// Add error class to input
66+
input.classList.add('govuk-file-upload--error')
67+
68+
// Update aria-describedby
69+
const ariaDescribedBy = input.getAttribute('aria-describedby') || ''
70+
if (!ariaDescribedBy.includes(errorSpan.id)) {
71+
input.setAttribute(
72+
'aria-describedby',
73+
ariaDescribedBy ? `${ariaDescribedBy} ${errorSpan.id}` : errorSpan.id
74+
)
75+
}
76+
}
77+
78+
/**
79+
* Clear error message from a file input
80+
* @param {HTMLInputElement} input - The file input element
81+
*/
82+
function clearError(input) {
83+
const formGroup = input.closest('.govuk-form-group')
84+
if (!formGroup) return
85+
86+
// Remove error class from form group
87+
formGroup.classList.remove('govuk-form-group--error')
88+
89+
// Remove error message
90+
const errorSpan = formGroup.querySelector('.govuk-error-message')
91+
if (errorSpan) {
92+
errorSpan.remove()
93+
}
94+
95+
// Remove error class from input
96+
input.classList.remove('govuk-file-upload--error')
97+
98+
// Clean up aria-describedby
99+
const ariaDescribedBy = input.getAttribute('aria-describedby')
100+
if (ariaDescribedBy) {
101+
const errorId = `${input.id}-error`
102+
const updatedAriaDescribedBy = ariaDescribedBy
103+
.split(' ')
104+
.filter(id => id !== errorId)
105+
.join(' ')
106+
107+
if (updatedAriaDescribedBy) {
108+
input.setAttribute('aria-describedby', updatedAriaDescribedBy)
109+
} else {
110+
input.removeAttribute('aria-describedby')
111+
}
112+
}
113+
}
114+
115+
/**
116+
* Validate file size for a file input
117+
* @param {HTMLInputElement} input - The file input element
118+
* @returns {boolean} Whether the file is valid
119+
*/
120+
function validateFileSize(input) {
121+
const file = input.files[0]
122+
123+
// No file selected - clear any existing errors
124+
if (!file) {
125+
clearError(input)
126+
return true
127+
}
128+
129+
const maxSizeInBytes = parseInt(input.dataset.maxFileSize, 10)
130+
131+
// No max size specified - skip validation
132+
if (!maxSizeInBytes) {
133+
return true
134+
}
135+
136+
// File is within size limits
137+
if (file.size <= maxSizeInBytes) {
138+
clearError(input)
139+
return true
140+
}
141+
142+
// File is too large - show error
143+
const maxSizeMB = maxSizeInBytes / BYTES_IN_MB
144+
const actualSize = formatFileSize(file.size)
145+
const errorMessage = `The selected file must be smaller than ${maxSizeMB}MB (file is ${actualSize})`
146+
147+
showError(input, errorMessage)
148+
149+
// Clear the file input
150+
input.value = ''
151+
152+
return false
153+
}
154+
155+
/**
156+
* Initialize file validation for all file inputs with data-max-file-size attribute
157+
*/
158+
export function initFileValidation() {
159+
const fileInputs = document.querySelectorAll('input[type="file"][data-max-file-size]')
160+
161+
fileInputs.forEach(input => {
162+
// Validate on file selection
163+
input.addEventListener('change', (event) => {
164+
validateFileSize(event.target)
165+
})
166+
})
167+
168+
// Also validate on form submission as a final check
169+
document.addEventListener('submit', (event) => {
170+
const form = event.target
171+
const fileInputs = form.querySelectorAll('input[type="file"][data-max-file-size]')
172+
173+
let hasErrors = false
174+
fileInputs.forEach(input => {
175+
if (!validateFileSize(input)) {
176+
hasErrors = true
177+
}
178+
})
179+
180+
if (hasErrors) {
181+
event.preventDefault()
182+
// Focus on the first error
183+
const firstError = form.querySelector('.govuk-file-upload--error')
184+
if (firstError) {
185+
firstError.focus()
186+
}
187+
}
188+
})
189+
}

0 commit comments

Comments
 (0)