diff --git a/.jules/palette.md b/.jules/palette.md index a163362..c41f72d 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -1,3 +1,7 @@ ## 2024-05-18 - Disabled and Loading States for Async Actions **Learning:** Adding explicit loading and disabled states to asynchronous action buttons (like "Refresh") provides immediate feedback, reducing user confusion and preventing double-submissions. **Action:** Always ensure that buttons triggering network requests visually indicate the loading state and are disabled until the request completes. + +## 2026-07-03 - 동적 테이블의 컨텍스트를 포함한 ARIA 레이블 추가 +**Learning:** 데이터 테이블 내의 일반적인 액션 버튼("상세" 또는 "상태" 등)은 다수의 행이 존재할 때 스크린 리더 사용자에게 심각한 접근성 문제를 야기합니다. 왜냐하면 어떤 항목에 적용되는 액션인지 컨텍스트를 잃기 때문입니다. +**Action:** 동적으로 테이블 행을 생성할 때, 액션 버튼과 링크의 `aria-label`에 컨텍스트(예: 행의 주된 엔티티 이름이나 ID)를 반드시 주입하여, 스크린 리더가 단순한 "상세" 대신 "document.pdf에 대한 상세 보기"로 읽어주도록 해야 합니다. diff --git a/src/main/resources/static/assets/viewer/demo.js b/src/main/resources/static/assets/viewer/demo.js index 84eb8f3..30d9f78 100644 --- a/src/main/resources/static/assets/viewer/demo.js +++ b/src/main/resources/static/assets/viewer/demo.js @@ -77,13 +77,16 @@ function updateJob(jobId, patch) { void refreshKpis(); } -function createLink(href, label) { +function createLink(href, label, accessibleName) { const link = document.createElement("a"); link.href = href; link.textContent = label; link.className = "table-link"; link.target = "_blank"; link.rel = "noopener noreferrer"; + if (accessibleName) { + link.setAttribute("aria-label", accessibleName); + } return link; } @@ -106,12 +109,15 @@ async function openJsonDocument(url, title) { : "Unable to load JSON evidence with the current tenant claim."; } -function createActionButton(label, onClick) { +function createActionButton(label, onClick, accessibleName) { const button = document.createElement("button"); button.type = "button"; button.textContent = label; button.className = "btn btn-secondary btn-compact"; button.addEventListener("click", onClick); + if (accessibleName) { + button.setAttribute("aria-label", accessibleName); + } return button; } @@ -134,6 +140,7 @@ function renderHistory(history = loadHistory()) { const submittedCell = document.createElement("td"); const actionsCell = document.createElement("td"); + const docName = job.fileName || job.jobId || "document"; fileCell.textContent = job.fileName || "Document"; statusCell.textContent = job.status || "SUBMITTED"; submittedCell.textContent = job.submittedAt || ""; @@ -142,13 +149,13 @@ function renderHistory(history = loadHistory()) { if (job.statusUrl) { actionsCell.appendChild(createActionButton("Details", () => { void openJobDetail(job); - })); + }, `View details for ${docName}`)); actionsCell.appendChild(createActionButton("Status JSON", () => { void openJsonDocument(job.statusUrl, "Clearfolio status JSON"); - })); + }, `Open status JSON for ${docName} in a new tab`)); } if (job.jobId) { - actionsCell.appendChild(createLink(`/viewer/${encodeURIComponent(job.jobId)}`, "Open viewer")); + actionsCell.appendChild(createLink(`/viewer/${encodeURIComponent(job.jobId)}`, "Open viewer", `Open viewer for ${docName} in a new tab`)); } row.append(fileCell, statusCell, submittedCell, actionsCell);