diff --git a/.jules/palette.md b/.jules/palette.md index a34ea59b..f0519cd4 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -13,3 +13,6 @@ ## 2026-07-13 - Async Table Actions UX **Learning:** Adding explicit loading and disabled states to table action buttons that invoke asynchronous processes helps prevent redundant API calls and visually assures the user that their request is being handled. **Action:** Consistently apply `disabled` state and `Loading...` text changes to inline table action buttons linked to async workflows, and carefully preserve underlying DOM structures with `Array.from(btn.childNodes)` during the loading cycle to avoid rendering regressions. +## 2026-08-06 - [동적 요소 접근성 개선] +**Learning:** 비동기 작업 시 버튼에 단순히 텍스트만 변경하는 것은 스크린 리더에게 상태 변화를 명확히 전달하지 않으며, 반복되는 버튼(예: 여러 항목의 'Details' 버튼)은 동일한 텍스트로 인해 맥락이 누락됨. +**Action:** 비동기 작업 시 `aria-busy="true"` 속성을 추가하여 로딩 상태를 명시적으로 알리고, 반복되는 버튼에는 `aria-label`을 활용하여 고유한 맥락(예: 파일명)을 부여함. diff --git a/src/main/resources/static/assets/viewer/demo.js b/src/main/resources/static/assets/viewer/demo.js index 51466a8b..ff35e3e2 100644 --- a/src/main/resources/static/assets/viewer/demo.js +++ b/src/main/resources/static/assets/viewer/demo.js @@ -81,13 +81,16 @@ function updateJob(jobId, patch, { refreshKpisAfterUpdate = true } = {}) { } } -function createLink(href, label) { +function createLink(href, label, ariaLabel) { const link = document.createElement("a"); link.href = href; link.textContent = label; link.className = "table-link"; link.target = "_blank"; link.rel = "noopener noreferrer"; + if (ariaLabel) { + link.setAttribute("aria-label", ariaLabel); + } return link; } @@ -110,11 +113,14 @@ async function openJsonDocument(url, title) { : "Unable to load JSON evidence with the current tenant claim."; } -function createActionButton(label, onClick) { +function createActionButton(label, onClick, ariaLabel) { const button = document.createElement("button"); button.type = "button"; button.textContent = label; button.className = "btn btn-secondary btn-compact"; + if (ariaLabel) { + button.setAttribute("aria-label", ariaLabel); + } button.addEventListener("click", onClick); return button; } @@ -138,7 +144,8 @@ function renderHistory(history = loadHistory()) { const submittedCell = document.createElement("td"); const actionsCell = document.createElement("td"); - fileCell.textContent = job.fileName || "Document"; + const fileName = job.fileName || "Document"; + fileCell.textContent = fileName; statusCell.textContent = job.status || "SUBMITTED"; submittedCell.textContent = job.submittedAt || ""; actionsCell.className = "table-actions"; @@ -149,17 +156,19 @@ function renderHistory(history = loadHistory()) { const initialChildren = Array.from(btn.childNodes); btn.disabled = true; btn.textContent = "Loading..."; + btn.setAttribute("aria-busy", "true"); openJobDetail(job).finally(() => { btn.replaceChildren(...initialChildren); btn.disabled = false; + btn.removeAttribute("aria-busy"); }); - })); + }, `View details for ${fileName}`)); actionsCell.appendChild(createActionButton("Status JSON", () => { void openJsonDocument(job.statusUrl, "Clearfolio status JSON"); - })); + }, `Open status JSON for ${fileName}`)); } if (job.jobId) { - actionsCell.appendChild(createLink(`/viewer/${encodeURIComponent(job.jobId)}`, "Open viewer")); + actionsCell.appendChild(createLink(`/viewer/${encodeURIComponent(job.jobId)}`, "Open viewer", `Open viewer for ${fileName}`)); } row.append(fileCell, statusCell, submittedCell, actionsCell); @@ -300,6 +309,7 @@ async function retryActiveJob() { const initialChildren = Array.from(el.retryJobBtn.childNodes); el.retryJobBtn.disabled = true; el.retryJobBtn.textContent = "Retrying..."; + el.retryJobBtn.setAttribute("aria-busy", "true"); setStatus("Requesting operator retry..."); try { @@ -340,6 +350,7 @@ async function retryActiveJob() { } finally { el.retryJobBtn.replaceChildren(...initialChildren); el.retryJobBtn.disabled = false; + el.retryJobBtn.removeAttribute("aria-busy"); } } @@ -415,6 +426,7 @@ async function refreshKpiEvidence() { const initialChildren = Array.from(el.refreshEvidenceBtn.childNodes); el.refreshEvidenceBtn.disabled = true; el.refreshEvidenceBtn.textContent = "Refreshing..."; + el.refreshEvidenceBtn.setAttribute("aria-busy", "true"); try { const { res, data } = await fetchJson(KPI_EXPORTS_ENDPOINT); @@ -429,6 +441,7 @@ async function refreshKpiEvidence() { } finally { el.refreshEvidenceBtn.replaceChildren(...initialChildren); el.refreshEvidenceBtn.disabled = false; + el.refreshEvidenceBtn.removeAttribute("aria-busy"); } } @@ -436,6 +449,7 @@ async function loadDemoData() { const initialChildren = Array.from(el.loadDemoDataBtn.childNodes); el.loadDemoDataBtn.disabled = true; el.loadDemoDataBtn.textContent = "Loading..."; + el.loadDemoDataBtn.setAttribute("aria-busy", "true"); setStatus("Loading seeded buyer-demo story..."); try { @@ -460,6 +474,7 @@ async function loadDemoData() { } finally { el.loadDemoDataBtn.replaceChildren(...initialChildren); el.loadDemoDataBtn.disabled = false; + el.loadDemoDataBtn.removeAttribute("aria-busy"); } } @@ -508,6 +523,7 @@ async function submitDocument(event) { const initialChildren = Array.from(el.submitBtn.childNodes); el.submitBtn.disabled = true; el.submitBtn.textContent = "Submitting..."; + el.submitBtn.setAttribute("aria-busy", "true"); setStatus("Submitting document..."); try { @@ -552,6 +568,7 @@ async function submitDocument(event) { } finally { el.submitBtn.replaceChildren(...initialChildren); el.submitBtn.disabled = false; + el.submitBtn.removeAttribute("aria-busy"); } }