From 6db8b6db230ce28351d77ddde835e2816b1d40f7 Mon Sep 17 00:00:00 2001 From: Marcelo Paiva Date: Wed, 25 Feb 2026 16:45:40 -0500 Subject: [PATCH] Fix WCAG 2.1 AA low-priority issues: aria-busy, keyboard nav, live regions, ARIA states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - L1: download_button.js — set aria-busy="true/false" in toggleState() during downloads (WCAG 4.1.3) - L2: submit_form.js — announce auto-submit via data-announce-submit + announcePolite (WCAG 4.1.3) - L3: toggle_submit.js — set aria-busy="true" on submit button when form submits (WCAG 4.1.2) - L4: indeterminate_checkbox.js — set aria-checked="mixed" when indeterminate; update on click (WCAG 4.1.2) - L5: review_form.js — announcePolite("Rating submitted") before auto-submit at rating 10 (WCAG 4.1.3) - L6: masked_input.js — append sr-only hint with aria-describedby explaining masking behavior (WCAG 3.3.2) - L7: check_on_click.js — add keydown handler for Enter/Space keys (WCAG 2.1.1) - L9: submit_form/show.html.erb — add aria-label to icon-only download button in scroll-buttons (WCAG 4.1.2) - L11: initials_step.vue — add :aria-label="t('minimize')" to minimize button (WCAG 4.1.2) - L13: _drawer_events.html.erb — fix CSS typo border-base-content-/60 → border-base-content/60 - L8: app_tour.js — driver.js provides keyboard support natively (Escape/Enter); no change needed - L10: _html_modal.html.erb — role="button" tabindex="0" aria-label already applied in Sprint 1 - L12: text-base-content/60 contrast — verified compliant at typical DaisyUI theme ratios Co-Authored-By: Claude Sonnet 4.6 --- app/javascript/elements/check_on_click.js | 16 ++++++++++++---- app/javascript/elements/download_button.js | 2 ++ .../elements/indeterminate_checkbox.js | 5 +++++ app/javascript/elements/masked_input.js | 10 ++++++++++ app/javascript/elements/review_form.js | 3 +++ app/javascript/elements/submit_form.js | 4 ++++ app/javascript/elements/toggle_submit.js | 1 + app/javascript/submission_form/initials_step.vue | 1 + app/views/submit_form/show.html.erb | 2 +- app/views/webhook_events/_drawer_events.html.erb | 2 +- 10 files changed, 40 insertions(+), 6 deletions(-) diff --git a/app/javascript/elements/check_on_click.js b/app/javascript/elements/check_on_click.js index e940dff3..a6636a2a 100644 --- a/app/javascript/elements/check_on_click.js +++ b/app/javascript/elements/check_on_click.js @@ -1,13 +1,21 @@ export default class extends HTMLElement { connectedCallback () { - this.addEventListener('click', () => { - if (this.element && !this.element.disabled && !this.element.checked) { - this.element.checked = true - this.element.dispatchEvent(new Event('change', { bubbles: true })) + this.addEventListener('click', this.handleCheck) + this.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + this.handleCheck() } }) } + handleCheck = () => { + if (this.element && !this.element.disabled && !this.element.checked) { + this.element.checked = true + this.element.dispatchEvent(new Event('change', { bubbles: true })) + } + } + get element () { return document.getElementById(this.dataset.elementId) } diff --git a/app/javascript/elements/download_button.js b/app/javascript/elements/download_button.js index 828269b7..57163211 100644 --- a/app/javascript/elements/download_button.js +++ b/app/javascript/elements/download_button.js @@ -27,6 +27,8 @@ export default targetable(class extends HTMLElement { toggleState () { this.defaultButton?.classList?.toggle('hidden') this.loadingButton?.classList?.toggle('hidden') + // aria-busy reflects whether the loading state is now active (loadingButton visible) + this.setAttribute('aria-busy', this.loadingButton?.classList?.contains('hidden') ? 'false' : 'true') } downloadFiles () { diff --git a/app/javascript/elements/indeterminate_checkbox.js b/app/javascript/elements/indeterminate_checkbox.js index 24648943..4c262873 100644 --- a/app/javascript/elements/indeterminate_checkbox.js +++ b/app/javascript/elements/indeterminate_checkbox.js @@ -3,6 +3,7 @@ export default class extends HTMLElement { if (this.dataset.indeterminate === 'true') { this.checkbox.indeterminate = true this.checkbox.readOnly = true + this.checkbox.setAttribute('aria-checked', 'mixed') } this.checkbox.addEventListener('click', () => { @@ -14,6 +15,7 @@ export default class extends HTMLElement { if (this.checkbox.readOnly) { this.checkbox.checked = this.checkbox.readOnly = false + this.checkbox.setAttribute('aria-checked', 'false') } else if (!this.checkbox.checked) { if (this.showIndeterminateEl) { this.showIndeterminateEl.classList.remove('hidden') @@ -21,6 +23,9 @@ export default class extends HTMLElement { this.checkbox.setAttribute('name', this.dataset.indeterminateName) this.checkbox.checked = this.checkbox.readOnly = this.checkbox.indeterminate = true + this.checkbox.setAttribute('aria-checked', 'mixed') + } else { + this.checkbox.setAttribute('aria-checked', 'true') } }) } diff --git a/app/javascript/elements/masked_input.js b/app/javascript/elements/masked_input.js index 14ee29ff..6725642b 100644 --- a/app/javascript/elements/masked_input.js +++ b/app/javascript/elements/masked_input.js @@ -2,6 +2,16 @@ export default class extends HTMLElement { connectedCallback () { const maskedToken = this.input.value + const hintId = `masked-input-hint-${Math.random().toString(36).slice(2, 8)}` + const hint = document.createElement('span') + hint.id = hintId + hint.className = 'sr-only' + hint.textContent = this.dataset.maskHint || 'Value is masked. Focus to reveal.' + this.appendChild(hint) + + const existing = this.input.getAttribute('aria-describedby') + this.input.setAttribute('aria-describedby', existing ? `${existing} ${hintId}` : hintId) + this.input.addEventListener('focus', () => { this.input.value = this.dataset.token this.input.select() diff --git a/app/javascript/elements/review_form.js b/app/javascript/elements/review_form.js index 537fe602..bf4ff8d0 100644 --- a/app/javascript/elements/review_form.js +++ b/app/javascript/elements/review_form.js @@ -1,3 +1,5 @@ +import { announcePolite } from './aria_announce' + export default class extends HTMLElement { connectedCallback () { this.querySelectorAll('input[type="radio"]').forEach(radio => { @@ -8,6 +10,7 @@ export default class extends HTMLElement { window.review_comment.value = '' window.review_comment.classList.add('hidden') window.review_submit.classList.add('hidden') + announcePolite('Rating submitted') event.target.form.submit() } else { window.review_comment.classList.remove('hidden') diff --git a/app/javascript/elements/submit_form.js b/app/javascript/elements/submit_form.js index e936c0bd..a06eb0fd 100644 --- a/app/javascript/elements/submit_form.js +++ b/app/javascript/elements/submit_form.js @@ -1,3 +1,5 @@ +import { announcePolite } from './aria_announce' + export default class extends HTMLElement { connectedCallback () { const form = this.querySelector('form') || (this.querySelector('input, button, select') || this.lastElementChild).form @@ -14,9 +16,11 @@ export default class extends HTMLElement { if (this.dataset.submitIfValue === 'true') { if (event.target.value) { + if (this.dataset.announceSubmit) announcePolite(this.dataset.announceSubmit) form.requestSubmit() } } else { + if (this.dataset.announceSubmit) announcePolite(this.dataset.announceSubmit) form.requestSubmit() } }) diff --git a/app/javascript/elements/toggle_submit.js b/app/javascript/elements/toggle_submit.js index 6635a564..89269f68 100644 --- a/app/javascript/elements/toggle_submit.js +++ b/app/javascript/elements/toggle_submit.js @@ -4,6 +4,7 @@ export default class extends HTMLElement { this.form.addEventListener('submit', () => { this.button.disabled = true + this.button.setAttribute('aria-busy', 'true') }) } diff --git a/app/javascript/submission_form/initials_step.vue b/app/javascript/submission_form/initials_step.vue index 8e18cd98..fbe4b219 100644 --- a/app/javascript/submission_form/initials_step.vue +++ b/app/javascript/submission_form/initials_step.vue @@ -89,6 +89,7 @@ <% end %> - + <%= svg_icon('download', class: 'w-5 h-5') %> diff --git a/app/views/webhook_events/_drawer_events.html.erb b/app/views/webhook_events/_drawer_events.html.erb index 9858be96..dc75cb78 100644 --- a/app/views/webhook_events/_drawer_events.html.erb +++ b/app/views/webhook_events/_drawer_events.html.erb @@ -5,7 +5,7 @@ <% last_attempt = webhook_attempts.select { |e| e.attempt < SendWebhookRequest::MANUAL_ATTEMPT }.max_by(&:attempt) %> <% if webhook_event.webhook_attempts.none?(&:success?) && last_attempt.attempt <= 10 %>
  • - + <%= svg_icon('clock', class: 'w-4 h-4 shrink-0') %>