Fix WCAG 2.1 AA medium/high issues: keyboard nav, canvas ARIA, live regions, focus management

- H7: Add user_menu.js custom element for navbar dropdown keyboard navigation
  (ArrowDown/Up, Enter/Space, Escape, roving tabindex, aria-expanded) (WCAG 2.1.1)
- H8: Add aria-label + fallback text to signature, initials, and QR canvas elements (WCAG 1.1.1)
- M3: Add role="textbox" + aria-label to field name contenteditable in area.vue (WCAG 4.1.2)
- M4: Add aria-hidden/aria-expanded to toggle_visible.js and field_condition.js (WCAG 4.1.2)
- M5: Add aria-label ("Show/Hide password") + aria-pressed to password_input.js toggle (WCAG 4.1.2)
- M6: Move focus to first input on addItem; announce removal via announcePolite in dynamic_list.js (WCAG 2.4.3)
- M7: Announce "Copied to clipboard" via announcePolite in clipboard_copy.js (WCAG 4.1.3)
- M9: Fix placeholder contrast text-neutral-400 → text-neutral-600 in contenteditable.vue (WCAG 1.4.3)
- M9: Fix placeholder contrast before:text-base-content/30 → /60 in template_builder/area.vue (WCAG 1.4.3)
- M11: Add persistent aria-live="polite" region for QR code appearance in signature_step.vue (WCAG 4.1.3)
- Fix missed alert() in initials_step.vue → initialsError data prop + live region (WCAG 4.1.3)
- Add canvas accessibility i18n keys: signature_drawing_pad, initials_drawing_pad, qr_code_for_mobile_signature
- Add field_name i18n key to template_builder for contenteditable aria-label

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
pull/599/head
Marcelo Paiva 3 weeks ago
parent 3b9145d9e8
commit bf4bbedc28

@ -54,6 +54,7 @@ import OpenModal from './elements/open_modal'
import BarChart from './elements/bar_chart' import BarChart from './elements/bar_chart'
import FieldCondition from './elements/field_condition' import FieldCondition from './elements/field_condition'
import DocumentTabs from './elements/document_tabs' import DocumentTabs from './elements/document_tabs'
import UserMenu from './elements/user_menu'
import * as TurboInstantClick from './lib/turbo_instant_click' import * as TurboInstantClick from './lib/turbo_instant_click'
@ -65,8 +66,10 @@ document.addEventListener('turbo:before-cache', () => {
document.addEventListener('keyup', (e) => { document.addEventListener('keyup', (e) => {
if (e.code === 'Escape') { if (e.code === 'Escape') {
if (!document.activeElement?.closest('user-menu')) {
document.activeElement?.blur() document.activeElement?.blur()
} }
}
}) })
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody) document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
@ -146,6 +149,7 @@ safeRegisterElement('open-modal', OpenModal)
safeRegisterElement('bar-chart', BarChart) safeRegisterElement('bar-chart', BarChart)
safeRegisterElement('field-condition', FieldCondition) safeRegisterElement('field-condition', FieldCondition)
safeRegisterElement('document-tabs', DocumentTabs) safeRegisterElement('document-tabs', DocumentTabs)
safeRegisterElement('user-menu', UserMenu)
safeRegisterElement('template-builder', class extends HTMLElement { safeRegisterElement('template-builder', class extends HTMLElement {
connectedCallback () { connectedCallback () {

@ -1,4 +1,4 @@
import { announceError } from './aria_announce' import { announceError, announcePolite } from './aria_announce'
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
@ -16,7 +16,7 @@ export default class extends HTMLElement {
const text = this.dataset.text || this.innerText.trim() const text = this.dataset.text || this.innerText.trim()
if (navigator.clipboard) { if (navigator.clipboard) {
navigator.clipboard.writeText(text) navigator.clipboard.writeText(text).then(() => announcePolite('Copied to clipboard'))
} else { } else {
if (e.target.tagName !== 'INPUT') { if (e.target.tagName !== 'INPUT') {
announceError(`Clipboard not available. Please use HTTPS. Copy text: ${text}`) announceError(`Clipboard not available. Please use HTTPS. Copy text: ${text}`)

@ -1,5 +1,6 @@
import { actionable } from '@github/catalyst/lib/actionable' import { actionable } from '@github/catalyst/lib/actionable'
import { targets, targetable } from '@github/catalyst/lib/targetable' import { targets, targetable } from '@github/catalyst/lib/targetable'
import { announcePolite } from './aria_announce'
export default actionable(targetable(class extends HTMLElement { export default actionable(targetable(class extends HTMLElement {
static [targets.static] = ['items'] static [targets.static] = ['items']
@ -24,11 +25,18 @@ export default actionable(targetable(class extends HTMLElement {
duplicateItem.querySelectorAll('a.hidden').forEach((button) => button.classList.toggle('hidden')) duplicateItem.querySelectorAll('a.hidden').forEach((button) => button.classList.toggle('hidden'))
originalItem.parentNode.append(duplicateItem) originalItem.parentNode.append(duplicateItem)
const firstInput = duplicateItem.querySelector("select, textarea, input:not([type='hidden'])")
if (firstInput) firstInput.focus()
} }
removeItem (e) { removeItem (e) {
e.preventDefault() e.preventDefault()
this.items.find((item) => item.contains(e.target))?.remove() const item = this.items.find((item) => item.contains(e.target))
if (item) {
item.remove()
announcePolite('Item removed')
}
} }
})) }))

@ -154,11 +154,13 @@ export default class extends HTMLElement {
if (passed) { if (passed) {
this.targetEl.style.display = '' this.targetEl.style.display = ''
this.targetEl.removeAttribute('aria-hidden')
this.targetEl.labels.forEach((label) => { label.style.display = '' }) this.targetEl.labels.forEach((label) => { label.style.display = '' })
controls.forEach((c) => (c.disabled = false)) controls.forEach((c) => (c.disabled = false))
} else { } else {
this.targetEl.style.display = 'none' this.targetEl.style.display = 'none'
this.targetEl.setAttribute('aria-hidden', 'true')
this.targetEl.labels.forEach((label) => { label.style.display = 'none' }) this.targetEl.labels.forEach((label) => { label.style.display = 'none' })
controls.forEach((c) => (c.disabled = true)) controls.forEach((c) => (c.disabled = true))

@ -17,6 +17,7 @@ export default targetable(class extends HTMLElement {
this.togglePasswordVisibility.setAttribute('role', 'button') this.togglePasswordVisibility.setAttribute('role', 'button')
} }
this.updateToggleAria()
this.togglePasswordVisibility.addEventListener('click', this.handleTogglePasswordVisibility) this.togglePasswordVisibility.addEventListener('click', this.handleTogglePasswordVisibility)
// Add keyboard support for Enter and Space keys // Add keyboard support for Enter and Space keys
@ -50,5 +51,12 @@ export default targetable(class extends HTMLElement {
toggleIcon = () => { toggleIcon = () => {
this.visiblePasswordIcon.classList.toggle('hidden', this.passwordInput.type === 'password') this.visiblePasswordIcon.classList.toggle('hidden', this.passwordInput.type === 'password')
this.hiddenPasswordIcon.classList.toggle('hidden', this.passwordInput.type === 'text') this.hiddenPasswordIcon.classList.toggle('hidden', this.passwordInput.type === 'text')
this.updateToggleAria()
}
updateToggleAria = () => {
const isVisible = this.passwordInput.type === 'text'
this.togglePasswordVisibility.setAttribute('aria-label', isVisible ? 'Hide password' : 'Show password')
this.togglePasswordVisibility.setAttribute('aria-pressed', isVisible ? 'true' : 'false')
} }
}) })

@ -6,11 +6,19 @@ export default actionable(class extends HTMLElement {
if (event.target.type === 'checkbox') { if (event.target.type === 'checkbox') {
elementIds.forEach((elementId) => { elementIds.forEach((elementId) => {
document.getElementById(elementId)?.classList.toggle('hidden') const el = document.getElementById(elementId)
if (!el) return
el.classList.toggle('hidden')
el.setAttribute('aria-hidden', el.classList.contains('hidden') ? 'true' : 'false')
}) })
event.target.setAttribute('aria-expanded', event.target.checked ? 'true' : 'false')
} else { } else {
elementIds.forEach((elementId) => { elementIds.forEach((elementId) => {
document.getElementById(elementId).classList.toggle('hidden', (event.target.dataset.toggleId || event.target.value) !== elementId) const el = document.getElementById(elementId)
if (!el) return
const hide = (event.target.dataset.toggleId || event.target.value) !== elementId
el.classList.toggle('hidden', hide)
el.setAttribute('aria-hidden', hide ? 'true' : 'false')
}) })
} }

@ -0,0 +1,80 @@
export default class extends HTMLElement {
connectedCallback () {
this._trigger = this.querySelector('[aria-haspopup]')
this._menu = this.querySelector('ul')
if (!this._trigger || !this._menu) return
this._menu.setAttribute('role', 'menu')
this._menu.querySelectorAll('a[href], button').forEach((el) => {
el.setAttribute('role', 'menuitem')
})
this.addEventListener('focusin', this._onFocusin)
this.addEventListener('focusout', this._onFocusout)
this._trigger.addEventListener('keydown', this._onTriggerKeydown)
this._menu.addEventListener('keydown', this._onMenuKeydown)
}
_onFocusin = () => {
this._trigger.setAttribute('aria-expanded', 'true')
}
_onFocusout = (e) => {
if (!this.contains(e.relatedTarget)) {
this._trigger.setAttribute('aria-expanded', 'false')
}
}
_onTriggerKeydown = (e) => {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
e.preventDefault()
this._focusItem(0)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
this._focusItem(-1)
}
}
_onMenuKeydown = (e) => {
const items = this._menuItems()
const idx = items.indexOf(document.activeElement)
if (e.key === 'ArrowDown') {
e.preventDefault()
items[(idx + 1) % items.length]?.focus()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
items[(idx - 1 + items.length) % items.length]?.focus()
} else if (e.key === 'Home') {
e.preventDefault()
items[0]?.focus()
} else if (e.key === 'End') {
e.preventDefault()
items[items.length - 1]?.focus()
} else if (e.key === 'Escape') {
e.preventDefault()
this._closeMenu()
}
}
_menuItems () {
return Array.from(this._menu.querySelectorAll('a[href], button:not([disabled])'))
}
_focusItem (idx) {
const items = this._menuItems()
const target = idx >= 0 ? items[idx] : items[items.length + idx]
target?.focus()
}
_closeMenu () {
// Force-hide while focusing trigger to prevent CSS :focus-within from re-opening it
this._menu.style.setProperty('display', 'none', 'important')
this._trigger.setAttribute('aria-expanded', 'false')
this._trigger.focus()
this._trigger.addEventListener('blur', () => {
this._menu.style.removeProperty('display')
}, { once: true })
}
}

@ -97,7 +97,10 @@ const en = {
upload: 'Upload', upload: 'Upload',
files: 'Files', files: 'Files',
signature_is_too_small_or_simple_please_redraw: 'Signature is too small or simple. Please redraw.', signature_is_too_small_or_simple_please_redraw: 'Signature is too small or simple. Please redraw.',
wait_countdown_seconds: 'Wait {countdown} seconds' wait_countdown_seconds: 'Wait {countdown} seconds',
signature_drawing_pad: 'Signature drawing pad. Use the tools above to draw or type your signature.',
initials_drawing_pad: 'Initials drawing pad. Use the tools above to draw or type your initials.',
qr_code_for_mobile_signature: 'QR code for signing on a mobile device.'
} }
const es = { const es = {

@ -133,7 +133,8 @@
v-show="!modelValue && !computedPreviousValue" v-show="!modelValue && !computedPreviousValue"
ref="canvas" ref="canvas"
class="bg-white border border-base-300 rounded-2xl w-full draw-canvas" class="bg-white border border-base-300 rounded-2xl w-full draw-canvas"
/> :aria-label="t('initials_drawing_pad')"
>{{ t('initials_drawing_pad') }}</canvas>
</div> </div>
<input <input
v-if="!isDrawInitials && !modelValue && !computedPreviousValue" v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
@ -146,6 +147,14 @@
@focus="$emit('focus')" @focus="$emit('focus')"
@input="updateWrittenInitials" @input="updateWrittenInitials"
> >
<div
v-if="initialsError"
role="alert"
aria-live="assertive"
class="text-error text-sm mt-2 px-1"
>
{{ initialsError }}
</div>
</div> </div>
</template> </template>
@ -212,7 +221,8 @@ export default {
isInitialsStarted: false, isInitialsStarted: false,
isUsePreviousValue: true, isUsePreviousValue: true,
isDrawInitials: false, isDrawInitials: false,
uploadImageInputKey: Math.random().toString() uploadImageInputKey: Math.random().toString(),
initialsError: null
} }
}, },
computed: { computed: {
@ -393,7 +403,7 @@ export default {
} }
}).catch((error) => { }).catch((error) => {
if (this.field.required === true) { if (this.field.required === true) {
alert(this.t('signature_is_too_small_or_simple_please_redraw')) this.initialsError = this.t('signature_is_too_small_or_simple_please_redraw')
return reject(error) return reject(error)
} else { } else {

@ -173,7 +173,8 @@
ref="canvas" ref="canvas"
style="padding: 1px; 0" style="padding: 1px; 0"
class="bg-white border border-base-300 rounded-2xl w-full draw-canvas" class="bg-white border border-base-300 rounded-2xl w-full draw-canvas"
/> :aria-label="t('signature_drawing_pad')"
>{{ t('signature_drawing_pad') }}</canvas>
<div <div
v-if="isShowQr" v-if="isShowQr"
class="top-0 bottom-0 right-0 left-0 absolute bg-white rounded-2xl m-0.5" class="top-0 bottom-0 right-0 left-0 absolute bg-white rounded-2xl m-0.5"
@ -200,7 +201,8 @@
class="h-full" class="h-full"
width="132" width="132"
height="132" height="132"
/> :aria-label="t('qr_code_for_mobile_signature')"
>{{ t('qr_code_for_mobile_signature') }}</canvas>
</div> </div>
</div> </div>
</div> </div>
@ -314,6 +316,11 @@
> >
{{ signatureError }} {{ signatureError }}
</div> </div>
<div
aria-live="polite"
aria-atomic="true"
class="sr-only"
>{{ qrAnnouncement }}</div>
</div> </div>
</template> </template>
@ -435,7 +442,8 @@ export default {
isTouchAttachment: false, isTouchAttachment: false,
isTextSignature: this.field.preferences?.format === 'typed' || this.field.preferences?.format === 'typed_or_upload', isTextSignature: this.field.preferences?.format === 'typed' || this.field.preferences?.format === 'typed_or_upload',
uploadImageInputKey: Math.random().toString(), uploadImageInputKey: Math.random().toString(),
signatureError: null signatureError: null,
qrAnnouncement: null
} }
}, },
computed: { computed: {
@ -577,6 +585,7 @@ export default {
}, },
showQr () { showQr () {
this.isShowQr = true this.isShowQr = true
this.qrAnnouncement = this.t('scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature')
this.$nextTick(() => { this.$nextTick(() => {
import('qr-creator').then(({ default: Qr }) => { import('qr-creator').then(({ default: Qr }) => {
@ -596,6 +605,7 @@ export default {
}, },
hideQr () { hideQr () {
this.isShowQr = false this.isShowQr = false
this.qrAnnouncement = null
this.stopCheckSignature() this.stopCheckSignature()
}, },

@ -63,6 +63,9 @@
v-if="field.type !== 'checkbox' || field.name" v-if="field.type !== 'checkbox' || field.name"
ref="name" ref="name"
:contenteditable="editable && !defaultField && field.type !== 'heading'" :contenteditable="editable && !defaultField && field.type !== 'heading'"
:role="editable && !defaultField && field.type !== 'heading' ? 'textbox' : undefined"
:aria-multiline="editable && !defaultField && field.type !== 'heading' ? 'false' : undefined"
:aria-label="editable && !defaultField && field.type !== 'heading' ? t('field_name') : undefined"
dir="auto" dir="auto"
class="pr-1 cursor-text block focus:ring-1 focus:ring-base-content/40 focus:rounded-sm" class="pr-1 cursor-text block focus:ring-1 focus:ring-base-content/40 focus:rounded-sm"
style="min-width: 2px" style="min-width: 2px"
@ -300,7 +303,7 @@
v-else v-else
ref="defaultValue" ref="defaultValue"
:contenteditable="isValueInput" :contenteditable="isValueInput"
class="whitespace-pre-wrap empty:before:content-[attr(placeholder)] before:text-base-content/30 focus:ring-1 focus:ring-base-content/40 focus:rounded-sm" class="whitespace-pre-wrap empty:before:content-[attr(placeholder)] before:text-base-content/60 focus:ring-1 focus:ring-base-content/40 focus:rounded-sm"
:class="{ 'cursor-text': isValueInput }" :class="{ 'cursor-text': isValueInput }"
:placeholder="withFieldPlaceholder && !isValueInput ? defaultField?.title || field.title || field.name || defaultName : (field.type === 'date' ? field.preferences?.format || t('type_value') : t('type_value'))" :placeholder="withFieldPlaceholder && !isValueInput ? defaultField?.title || field.title || field.name || defaultName : (field.type === 'date' ? field.preferences?.format || t('type_value') : t('type_value'))"
@blur="onDefaultValueBlur" @blur="onDefaultValueBlur"

@ -11,7 +11,7 @@
:data-empty="isEmpty" :data-empty="isEmpty"
:style="{ minWidth }" :style="{ minWidth }"
:class="[iconInline ? (isEmpty ? 'inline-block' : 'inline') : 'block', hideIcon ? 'focus:block' : '']" :class="[iconInline ? (isEmpty ? 'inline-block' : 'inline') : 'block', hideIcon ? 'focus:block' : '']"
class="peer relative inline-block outline-none before:pointer-events-none before:absolute before:left-0 before:top-0 before:select-none before:whitespace-pre before:text-neutral-400 before:content-[attr(data-placeholder)] before:opacity-0 data-[empty=true]:before:opacity-100" class="peer relative inline-block outline-none before:pointer-events-none before:absolute before:left-0 before:top-0 before:select-none before:whitespace-pre before:text-neutral-600 before:content-[attr(data-placeholder)] before:opacity-0 data-[empty=true]:before:opacity-100"
@paste.prevent="onPaste" @paste.prevent="onPaste"
@keydown.enter.prevent="blurContenteditable" @keydown.enter.prevent="blurContenteditable"
@input="updateInputValue" @input="updateInputValue"

@ -211,7 +211,8 @@ const en = {
align_bottom: 'Align Bottom', align_bottom: 'Align Bottom',
fields_selected: '{count} Fields Selected', fields_selected: '{count} Fields Selected',
field_added: '{count} Field Added', field_added: '{count} Field Added',
fields_added: '{count} Fields Added' fields_added: '{count} Fields Added',
field_name: 'Field name'
} }
const es = { const es = {

@ -23,8 +23,9 @@
<%= link_to t('settings'), settings_profile_index_path, class: 'hidden md:inline-flex font-medium text-lg', id: 'account_settings_button' %> <%= link_to t('settings'), settings_profile_index_path, class: 'hidden md:inline-flex font-medium text-lg', id: 'account_settings_button' %>
</div> </div>
<% end %> <% end %>
<user-menu>
<div class="dropdown dropdown-end"> <div class="dropdown dropdown-end">
<label tabindex="0" class="cursor-pointer bg-base-content text-purple-300 rounded-full p-2 w-9 justify-center flex" role="button" aria-label="<%= t('user_menu') %>" aria-haspopup="true" aria-expanded="false"> <label tabindex="0" class="cursor-pointer bg-base-content text-purple-300 rounded-full p-2 w-9 justify-center flex" role="button" aria-label="<%= t('user_menu') %>" aria-haspopup="menu" aria-expanded="false">
<span class="text-sm align-text-top" aria-hidden="true"><%= current_user.initials %></span> <span class="text-sm align-text-top" aria-hidden="true"><%= current_user.initials %></span>
</label> </label>
<ul tabindex="0" class="z-10 dropdown-content p-2 mt-2 shadow menu text-base bg-base-100 rounded-box min-w-[160px] text-right"> <ul tabindex="0" class="z-10 dropdown-content p-2 mt-2 shadow menu text-base bg-base-100 rounded-box min-w-[160px] text-right">
@ -78,6 +79,7 @@
</li> </li>
</ul> </ul>
</div> </div>
</user-menu>
</div> </div>
<% else %> <% else %>
<div class="flex space-x-2"> <div class="flex space-x-2">

Loading…
Cancel
Save