diff --git a/.jules/palette.md b/.jules/palette.md index 2dcd639e..3d3cf843 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -1,3 +1,7 @@ +## 2026-08-28 - 필수 입력 폼의 클라이언트 측 검증 피드백 개선 +**Learning:** 필수 입력 폼 필드를 비웠을 때 커스텀 검증 로직이 조용히 상태를 초기화하면, 네이티브 HTML5 유효성 검사 피드백이 나타나기 전까지 스크린 리더와 시각적 피드백이 사라져 사용자에게 혼란을 줍니다. +**Action:** 필수 입력 필드가 비워졌을 때(예: 값이 빈 문자열이거나 파일이 없는 경우) 명시적으로 인라인 오류 메시지('This field is required.')를 설정하고 `aria-invalid="true"`를 적용하여 시각적 오류 표시(빨간색 테두리 등) 및 스크린 리더를 위한 누락 상태를 명확히 해야 합니다. + ## 2024-07-15 - Dynamic Size formatting and Total Size Validation **Learning:** Hardcoding human-readable sizes (like '5 GiB') in validation error messages is error-prone when the underlying constant changes. Moreover, failing to validate total upload size against backend limits (e.g., MAX_UPLOAD_BYTES) in batch file uploads frustrates users who wait for a large upload to finish only to get a server-side 413 Payload Too Large error. **Action:** Always format backend byte limit constants dynamically (e.g., `formatBinaryBytes(MAX_UPLOAD_BYTES)`) on the client side to display accurate error messages. For multiple file inputs, ensure both the file count and the combined file size are validated against backend limits, giving immediate inline feedback via `setCustomValidity` and `aria-invalid`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9313538b..ed62424d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,3 +12,4 @@ ### Fixed - 단일·일괄 대상 크기 입력을 비웠을 때 이전 custom validity와 `aria-invalid` 상태를 즉시 초기화해 현재 필수 입력 상태를 정확히 전달합니다. - 업로드 파일명의 경로 구분자를 정규화하여 POSIX에서도 Windows 형식의 클라이언트 경로가 일관된 basename으로 기록되도록 수정했습니다. +- 단일/일괄 파일 입력 및 대상 바이트 입력 필드를 비울 때 커스텀 JS 검증이 조용히 상태를 초기화하여 접근성 피드백이 누락되는 문제를 해결하고, 빨간색 에러 메시지와 `aria-invalid`를 설정하여 폼 상태 가시성을 개선했습니다. diff --git a/saas_web.py b/saas_web.py index 63265e94..48696a77 100644 --- a/saas_web.py +++ b/saas_web.py @@ -225,7 +225,10 @@ async def add_security_headers(request: Request, call_next): input.removeAttribute('aria-invalid'); preview.style.color = '#0f6674'; if (!file) { - preview.innerText = ''; + preview.innerText = 'This field is required.'; + preview.style.color = '#dc3545'; + input.setCustomValidity('This field is required.'); + input.setAttribute('aria-invalid', 'true'); return; } const text = formatBinaryBytes(file.size); @@ -257,9 +260,10 @@ async def add_security_headers(request: Request, call_next): }); if (this.value === '') { - preview.innerText = ''; - this.setCustomValidity(''); - this.removeAttribute('aria-invalid'); + preview.innerText = 'This field is required.'; + preview.style.color = '#dc3545'; + this.setCustomValidity('This field is required.'); + this.setAttribute('aria-invalid', 'true'); return; } @@ -291,9 +295,10 @@ async def add_security_headers(request: Request, call_next): }); if (this.value === '') { - preview.innerText = ''; - this.setCustomValidity(''); - this.removeAttribute('aria-invalid'); + preview.innerText = 'This field is required.'; + preview.style.color = '#dc3545'; + this.setCustomValidity('This field is required.'); + this.setAttribute('aria-invalid', 'true'); return; } @@ -324,7 +329,10 @@ async def add_security_headers(request: Request, call_next): const files = input.files; if (!files || files.length === 0) { - preview.innerText = ''; + preview.innerText = 'This field is required.'; + preview.style.color = '#dc3545'; + input.setCustomValidity('This field is required.'); + input.setAttribute('aria-invalid', 'true'); return; } diff --git a/tests/test_empty_target_validation.py b/tests/test_empty_target_validation.py index 56374432..6eb8af49 100644 --- a/tests/test_empty_target_validation.py +++ b/tests/test_empty_target_validation.py @@ -51,9 +51,10 @@ def _assert_empty_branch(self, handler: str) -> None: empty_marker = "if (this.value === '') {" invalid_marker = "if (isNaN(val) || val <= 0) {" self.assertIn(empty_marker, handler) - self.assertIn("preview.innerText = '';", handler) - self.assertIn("this.setCustomValidity('');", handler) - self.assertIn("this.removeAttribute('aria-invalid');", handler) + self.assertIn("preview.innerText = 'This field is required.';", handler) + self.assertIn("preview.style.color = '#dc3545';", handler) + self.assertIn("this.setCustomValidity('This field is required.');", handler) + self.assertIn("this.setAttribute('aria-invalid', 'true');", handler) self.assertIn( "return;", handler[handler.index(empty_marker) : handler.index(invalid_marker)],