diff --git a/app/javascript/application.js b/app/javascript/application.js index bbe5e69f..ed0b1355 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -54,6 +54,7 @@ import OpenModal from './elements/open_modal' import BarChart from './elements/bar_chart' import FieldCondition from './elements/field_condition' import DocumentTabs from './elements/document_tabs' +import UserMenu from './elements/user_menu' import * as TurboInstantClick from './lib/turbo_instant_click' @@ -65,7 +66,9 @@ document.addEventListener('turbo:before-cache', () => { document.addEventListener('keyup', (e) => { if (e.code === 'Escape') { - document.activeElement?.blur() + if (!document.activeElement?.closest('user-menu')) { + document.activeElement?.blur() + } } }) @@ -146,6 +149,7 @@ safeRegisterElement('open-modal', OpenModal) safeRegisterElement('bar-chart', BarChart) safeRegisterElement('field-condition', FieldCondition) safeRegisterElement('document-tabs', DocumentTabs) +safeRegisterElement('user-menu', UserMenu) safeRegisterElement('template-builder', class extends HTMLElement { connectedCallback () { diff --git a/app/javascript/elements/clipboard_copy.js b/app/javascript/elements/clipboard_copy.js index 50a8ee3a..39571519 100644 --- a/app/javascript/elements/clipboard_copy.js +++ b/app/javascript/elements/clipboard_copy.js @@ -1,4 +1,4 @@ -import { announceError } from './aria_announce' +import { announceError, announcePolite } from './aria_announce' export default class extends HTMLElement { connectedCallback () { @@ -16,7 +16,7 @@ export default class extends HTMLElement { const text = this.dataset.text || this.innerText.trim() if (navigator.clipboard) { - navigator.clipboard.writeText(text) + navigator.clipboard.writeText(text).then(() => announcePolite('Copied to clipboard')) } else { if (e.target.tagName !== 'INPUT') { announceError(`Clipboard not available. Please use HTTPS. Copy text: ${text}`) diff --git a/app/javascript/elements/dynamic_list.js b/app/javascript/elements/dynamic_list.js index 57c9ccf1..d41d1d64 100644 --- a/app/javascript/elements/dynamic_list.js +++ b/app/javascript/elements/dynamic_list.js @@ -1,5 +1,6 @@ import { actionable } from '@github/catalyst/lib/actionable' import { targets, targetable } from '@github/catalyst/lib/targetable' +import { announcePolite } from './aria_announce' export default actionable(targetable(class extends HTMLElement { 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')) originalItem.parentNode.append(duplicateItem) + + const firstInput = duplicateItem.querySelector("select, textarea, input:not([type='hidden'])") + if (firstInput) firstInput.focus() } removeItem (e) { 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') + } } })) diff --git a/app/javascript/elements/field_condition.js b/app/javascript/elements/field_condition.js index b99a8bb3..08573edd 100644 --- a/app/javascript/elements/field_condition.js +++ b/app/javascript/elements/field_condition.js @@ -154,11 +154,13 @@ export default class extends HTMLElement { if (passed) { this.targetEl.style.display = '' + this.targetEl.removeAttribute('aria-hidden') this.targetEl.labels.forEach((label) => { label.style.display = '' }) controls.forEach((c) => (c.disabled = false)) } else { this.targetEl.style.display = 'none' + this.targetEl.setAttribute('aria-hidden', 'true') this.targetEl.labels.forEach((label) => { label.style.display = 'none' }) controls.forEach((c) => (c.disabled = true)) diff --git a/app/javascript/elements/password_input.js b/app/javascript/elements/password_input.js index 9db2f77d..8d19202d 100644 --- a/app/javascript/elements/password_input.js +++ b/app/javascript/elements/password_input.js @@ -17,6 +17,7 @@ export default targetable(class extends HTMLElement { this.togglePasswordVisibility.setAttribute('role', 'button') } + this.updateToggleAria() this.togglePasswordVisibility.addEventListener('click', this.handleTogglePasswordVisibility) // Add keyboard support for Enter and Space keys @@ -50,5 +51,12 @@ export default targetable(class extends HTMLElement { toggleIcon = () => { this.visiblePasswordIcon.classList.toggle('hidden', this.passwordInput.type === 'password') 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') } }) diff --git a/app/javascript/elements/toggle_visible.js b/app/javascript/elements/toggle_visible.js index 7fa968f2..45243b17 100644 --- a/app/javascript/elements/toggle_visible.js +++ b/app/javascript/elements/toggle_visible.js @@ -6,11 +6,19 @@ export default actionable(class extends HTMLElement { if (event.target.type === 'checkbox') { 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 { 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') }) } diff --git a/app/javascript/elements/user_menu.js b/app/javascript/elements/user_menu.js new file mode 100644 index 00000000..9eb65970 --- /dev/null +++ b/app/javascript/elements/user_menu.js @@ -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 }) + } +} diff --git a/app/javascript/submission_form/i18n.js b/app/javascript/submission_form/i18n.js index 70567992..9bb56836 100644 --- a/app/javascript/submission_form/i18n.js +++ b/app/javascript/submission_form/i18n.js @@ -97,7 +97,10 @@ const en = { upload: 'Upload', files: 'Files', 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 = { diff --git a/app/javascript/submission_form/initials_step.vue b/app/javascript/submission_form/initials_step.vue index d7017889..8e18cd98 100644 --- a/app/javascript/submission_form/initials_step.vue +++ b/app/javascript/submission_form/initials_step.vue @@ -133,7 +133,8 @@ v-show="!modelValue && !computedPreviousValue" ref="canvas" class="bg-white border border-base-300 rounded-2xl w-full draw-canvas" - /> + :aria-label="t('initials_drawing_pad')" + >{{ t('initials_drawing_pad') }} +