diff --git a/.jules/palette.md b/.jules/palette.md index a34ea59..affd5de 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -13,3 +13,7 @@ ## 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-07-14 - 동적 테이블 및 비동기 버튼 접근성 향상 +**Learning:** 동적으로 생성되는 테이블 행 내의 아이콘/텍스트 버튼이나 링크(예: "Details", "Status JSON")는 스크린 리더 사용자에게 컨텍스트를 제공하지 못하는 경우가 많습니다. 또한, 비동기 작업 시 로딩 상태를 표시할 때 기존 노드를 단순히 삭제/재성성하는 대신 `innerHTML`을 백업/복원하면서 `aria-busy="true"` 속성을 부여하는 패턴이 이 프로젝트의 컴포넌트 접근성 및 상태 유지에 더 적합함을 확인했습니다. +**Action:** 앞으로 동적 테이블 액션 버튼을 생성할 때는 반드시 row의 고유 정보(예: 파일명)를 포함한 `aria-label`을 전달하고, 비동기 상태 토글 시에는 `innerHTML` 기반 복원과 `aria-busy` 속성을 기본 패턴으로 적용하겠습니다. diff --git a/src/main/resources/static/assets/viewer/demo.js b/src/main/resources/static/assets/viewer/demo.js index 51466a8..09a893e 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; } @@ -146,20 +152,22 @@ function renderHistory(history = loadHistory()) { if (job.statusUrl) { actionsCell.appendChild(createActionButton("Details", (e) => { const btn = e.currentTarget; - const initialChildren = Array.from(btn.childNodes); + const initialHtml = btn.innerHTML; btn.disabled = true; btn.textContent = "Loading..."; + btn.setAttribute("aria-busy", "true"); openJobDetail(job).finally(() => { - btn.replaceChildren(...initialChildren); + btn.removeAttribute("aria-busy"); + btn.innerHTML = initialHtml; btn.disabled = false; }); - })); + }, `View details for ${job.fileName || "Document"}`)); actionsCell.appendChild(createActionButton("Status JSON", () => { void openJsonDocument(job.statusUrl, "Clearfolio status JSON"); - })); + }, `View status JSON for ${job.fileName || "Document"}`)); } if (job.jobId) { - actionsCell.appendChild(createLink(`/viewer/${encodeURIComponent(job.jobId)}`, "Open viewer")); + actionsCell.appendChild(createLink(`/viewer/${encodeURIComponent(job.jobId)}`, "Open viewer", `Open viewer for ${job.fileName || "Document"}`)); } row.append(fileCell, statusCell, submittedCell, actionsCell); @@ -297,9 +305,10 @@ async function retryActiveJob() { } const jobId = activeJobDetail.jobId; - const initialChildren = Array.from(el.retryJobBtn.childNodes); + const initialHtml = el.retryJobBtn.innerHTML; el.retryJobBtn.disabled = true; el.retryJobBtn.textContent = "Retrying..."; + el.retryJobBtn.setAttribute("aria-busy", "true"); setStatus("Requesting operator retry..."); try { @@ -338,7 +347,8 @@ async function retryActiveJob() { } catch (err) { setError("Network error while requesting retry. Retry when the service is reachable."); } finally { - el.retryJobBtn.replaceChildren(...initialChildren); + el.retryJobBtn.removeAttribute("aria-busy"); + el.retryJobBtn.innerHTML = initialHtml; el.retryJobBtn.disabled = false; } } @@ -412,9 +422,10 @@ async function refreshKpis() { } async function refreshKpiEvidence() { - const initialChildren = Array.from(el.refreshEvidenceBtn.childNodes); + const initialHtml = el.refreshEvidenceBtn.innerHTML; el.refreshEvidenceBtn.disabled = true; el.refreshEvidenceBtn.textContent = "Refreshing..."; + el.refreshEvidenceBtn.setAttribute("aria-busy", "true"); try { const { res, data } = await fetchJson(KPI_EXPORTS_ENDPOINT); @@ -427,15 +438,17 @@ async function refreshKpiEvidence() { } catch (err) { el.kpiExportStatus.textContent = "Snapshot evidence is unavailable while the service is unreachable."; } finally { - el.refreshEvidenceBtn.replaceChildren(...initialChildren); + el.refreshEvidenceBtn.removeAttribute("aria-busy"); + el.refreshEvidenceBtn.innerHTML = initialHtml; el.refreshEvidenceBtn.disabled = false; } } async function loadDemoData() { - const initialChildren = Array.from(el.loadDemoDataBtn.childNodes); + const initialHtml = el.loadDemoDataBtn.innerHTML; el.loadDemoDataBtn.disabled = true; el.loadDemoDataBtn.textContent = "Loading..."; + el.loadDemoDataBtn.setAttribute("aria-busy", "true"); setStatus("Loading seeded buyer-demo story..."); try { @@ -458,7 +471,8 @@ async function loadDemoData() { } catch (err) { setError("Unable to load seeded demo story."); } finally { - el.loadDemoDataBtn.replaceChildren(...initialChildren); + el.loadDemoDataBtn.removeAttribute("aria-busy"); + el.loadDemoDataBtn.innerHTML = initialHtml; el.loadDemoDataBtn.disabled = false; } } @@ -505,9 +519,10 @@ async function submitDocument(event) { return; } - const initialChildren = Array.from(el.submitBtn.childNodes); + const initialHtml = el.submitBtn.innerHTML; el.submitBtn.disabled = true; el.submitBtn.textContent = "Submitting..."; + el.submitBtn.setAttribute("aria-busy", "true"); setStatus("Submitting document..."); try { @@ -550,7 +565,8 @@ async function submitDocument(event) { addFailedHistory(file.name, "FAILED"); setError("Network error while submitting. Retry when the service is reachable."); } finally { - el.submitBtn.replaceChildren(...initialChildren); + el.submitBtn.removeAttribute("aria-busy"); + el.submitBtn.innerHTML = initialHtml; el.submitBtn.disabled = false; } } diff --git a/src/test/java/com/clearfolio/viewer/controller/ViewerUiControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/ViewerUiControllerTest.java index fa2dcfc..421ad92 100644 --- a/src/test/java/com/clearfolio/viewer/controller/ViewerUiControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/ViewerUiControllerTest.java @@ -135,6 +135,10 @@ void demoScriptUsesExistingApiAndSessionHistory() throws Exception { assertTrue(script.contains("X-Clearfolio-Tenant-Id")); assertTrue(script.contains("X-Clearfolio-Permissions")); assertTrue(script.contains("deadLettered")); + assertTrue(script.contains("ariaLabel")); + assertTrue(script.contains("aria-label")); + assertTrue(script.contains("aria-busy")); + assertTrue(script.contains("initialHtml = btn.innerHTML")); } }