From 29115d85412a0bb6738c43d0e3d51db16db27a9a Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:37:59 +0000 Subject: [PATCH] UX: Improve accessibility for asynchronous table actions - Add dynamic aria-label to table action buttons for screen readers (e.g. "Details for file.pdf"). - Dynamically update and restore aria-label during loading and refreshing states in demo.js and viewer.js. - Document loading state aria-label practices in .jules/palette.md. --- .jules/palette.md | 3 ++ .../resources/static/assets/viewer/demo.js | 54 ++++++++++++++++--- .../resources/static/assets/viewer/viewer.js | 17 ++++++ 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/.jules/palette.md b/.jules/palette.md index a34ea59b..600a7ce9 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-07-23 - Handle aria-label on loading states +**Learning:** Screen readers prioritize `aria-label` over text content. When dynamically updating a button's text content (like changing to "Loading..."), failing to also update its `aria-label` causes screen readers to miss the critical loading feedback and announce the original label instead. +**Action:** When temporarily changing a button to a loading state, store its original `aria-label` (if any), update the `aria-label` to match the loading text, and restore the original label after the async operation completes. diff --git a/src/main/resources/static/assets/viewer/demo.js b/src/main/resources/static/assets/viewer/demo.js index 51466a8b..77dec8e2 100644 --- a/src/main/resources/static/assets/viewer/demo.js +++ b/src/main/resources/static/assets/viewer/demo.js @@ -81,10 +81,13 @@ 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; + if (ariaLabel) { + link.setAttribute("aria-label", ariaLabel); + } link.className = "table-link"; link.target = "_blank"; link.rel = "noopener noreferrer"; @@ -110,10 +113,13 @@ 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; + if (ariaLabel) { + button.setAttribute("aria-label", ariaLabel); + } button.className = "btn btn-secondary btn-compact"; 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"; @@ -147,19 +154,26 @@ function renderHistory(history = loadHistory()) { actionsCell.appendChild(createActionButton("Details", (e) => { const btn = e.currentTarget; const initialChildren = Array.from(btn.childNodes); + const originalAriaLabel = btn.getAttribute("aria-label"); btn.disabled = true; btn.textContent = "Loading..."; + if (originalAriaLabel) { + btn.setAttribute("aria-label", "Loading..."); + } openJobDetail(job).finally(() => { btn.replaceChildren(...initialChildren); btn.disabled = false; + if (originalAriaLabel) { + btn.setAttribute("aria-label", originalAriaLabel); + } }); - })); + }, `Details for ${fileName}`)); actionsCell.appendChild(createActionButton("Status JSON", () => { void openJsonDocument(job.statusUrl, "Clearfolio status JSON"); - })); + }, `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); @@ -298,8 +312,12 @@ async function retryActiveJob() { const jobId = activeJobDetail.jobId; const initialChildren = Array.from(el.retryJobBtn.childNodes); + const originalAriaLabel = el.retryJobBtn.getAttribute("aria-label"); el.retryJobBtn.disabled = true; el.retryJobBtn.textContent = "Retrying..."; + if (originalAriaLabel) { + el.retryJobBtn.setAttribute("aria-label", "Retrying..."); + } setStatus("Requesting operator retry..."); try { @@ -340,6 +358,9 @@ async function retryActiveJob() { } finally { el.retryJobBtn.replaceChildren(...initialChildren); el.retryJobBtn.disabled = false; + if (originalAriaLabel) { + el.retryJobBtn.setAttribute("aria-label", originalAriaLabel); + } } } @@ -413,8 +434,12 @@ async function refreshKpis() { async function refreshKpiEvidence() { const initialChildren = Array.from(el.refreshEvidenceBtn.childNodes); + const originalAriaLabel = el.refreshEvidenceBtn.getAttribute("aria-label"); el.refreshEvidenceBtn.disabled = true; el.refreshEvidenceBtn.textContent = "Refreshing..."; + if (originalAriaLabel) { + el.refreshEvidenceBtn.setAttribute("aria-label", "Refreshing..."); + } try { const { res, data } = await fetchJson(KPI_EXPORTS_ENDPOINT); @@ -429,13 +454,20 @@ async function refreshKpiEvidence() { } finally { el.refreshEvidenceBtn.replaceChildren(...initialChildren); el.refreshEvidenceBtn.disabled = false; + if (originalAriaLabel) { + el.refreshEvidenceBtn.setAttribute("aria-label", originalAriaLabel); + } } } async function loadDemoData() { const initialChildren = Array.from(el.loadDemoDataBtn.childNodes); + const originalAriaLabel = el.loadDemoDataBtn.getAttribute("aria-label"); el.loadDemoDataBtn.disabled = true; el.loadDemoDataBtn.textContent = "Loading..."; + if (originalAriaLabel) { + el.loadDemoDataBtn.setAttribute("aria-label", "Loading..."); + } setStatus("Loading seeded buyer-demo story..."); try { @@ -460,6 +492,9 @@ async function loadDemoData() { } finally { el.loadDemoDataBtn.replaceChildren(...initialChildren); el.loadDemoDataBtn.disabled = false; + if (originalAriaLabel) { + el.loadDemoDataBtn.setAttribute("aria-label", originalAriaLabel); + } } } @@ -506,8 +541,12 @@ async function submitDocument(event) { } const initialChildren = Array.from(el.submitBtn.childNodes); + const originalAriaLabel = el.submitBtn.getAttribute("aria-label"); el.submitBtn.disabled = true; el.submitBtn.textContent = "Submitting..."; + if (originalAriaLabel) { + el.submitBtn.setAttribute("aria-label", "Submitting..."); + } setStatus("Submitting document..."); try { @@ -552,6 +591,9 @@ async function submitDocument(event) { } finally { el.submitBtn.replaceChildren(...initialChildren); el.submitBtn.disabled = false; + if (originalAriaLabel) { + el.submitBtn.setAttribute("aria-label", originalAriaLabel); + } } } diff --git a/src/main/resources/static/assets/viewer/viewer.js b/src/main/resources/static/assets/viewer/viewer.js index 051d570d..c5122d0b 100644 --- a/src/main/resources/static/assets/viewer/viewer.js +++ b/src/main/resources/static/assets/viewer/viewer.js @@ -50,8 +50,13 @@ function setLoading(message) { el.error.hidden = true; el.liveStatus.textContent = message; el.preview.setAttribute("aria-busy", "true"); + const originalAriaLabel = el.retryBtn.getAttribute("aria-label"); + el.retryBtn.dataset.originalAriaLabel = originalAriaLabel || ""; el.retryBtn.disabled = true; el.retryBtn.textContent = "Refreshing..."; + if (originalAriaLabel) { + el.retryBtn.setAttribute("aria-label", "Refreshing..."); + } } function showError(message) { @@ -62,6 +67,12 @@ function showError(message) { el.errorTitle.focus(); el.retryBtn.disabled = false; el.retryBtn.textContent = "Refresh"; + const originalAriaLabel = el.retryBtn.dataset.originalAriaLabel; + if (originalAriaLabel) { + el.retryBtn.setAttribute("aria-label", originalAriaLabel); + } else { + el.retryBtn.removeAttribute("aria-label"); + } } function clearPreview() { @@ -211,6 +222,12 @@ async function poll(docId, abortSignal) { el.liveStatus.textContent = "Ready."; el.retryBtn.disabled = false; el.retryBtn.textContent = "Refresh"; + const originalAriaLabel = el.retryBtn.dataset.originalAriaLabel; + if (originalAriaLabel) { + el.retryBtn.setAttribute("aria-label", originalAriaLabel); + } else { + el.retryBtn.removeAttribute("aria-label"); + } clearPreview(); const path = bootstrap.data.previewResourcePath;