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') }} + @@ -212,7 +221,8 @@ export default { isInitialsStarted: false, isUsePreviousValue: true, isDrawInitials: false, - uploadImageInputKey: Math.random().toString() + uploadImageInputKey: Math.random().toString(), + initialsError: null } }, computed: { @@ -393,7 +403,7 @@ export default { } }).catch((error) => { 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) } else { diff --git a/app/javascript/submission_form/signature_step.vue b/app/javascript/submission_form/signature_step.vue index c77e6c70..092e6c0c 100644 --- a/app/javascript/submission_form/signature_step.vue +++ b/app/javascript/submission_form/signature_step.vue @@ -173,7 +173,8 @@ ref="canvas" style="padding: 1px; 0" class="bg-white border border-base-300 rounded-2xl w-full draw-canvas" - /> + :aria-label="t('signature_drawing_pad')" + >{{ t('signature_drawing_pad') }}
+ :aria-label="t('qr_code_for_mobile_signature')" + >{{ t('qr_code_for_mobile_signature') }}
@@ -314,6 +316,11 @@ > {{ signatureError }} +
{{ qrAnnouncement }}
@@ -435,7 +442,8 @@ export default { isTouchAttachment: false, isTextSignature: this.field.preferences?.format === 'typed' || this.field.preferences?.format === 'typed_or_upload', uploadImageInputKey: Math.random().toString(), - signatureError: null + signatureError: null, + qrAnnouncement: null } }, computed: { @@ -577,6 +585,7 @@ export default { }, showQr () { 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(() => { import('qr-creator').then(({ default: Qr }) => { @@ -596,6 +605,7 @@ export default { }, hideQr () { this.isShowQr = false + this.qrAnnouncement = null this.stopCheckSignature() }, diff --git a/app/javascript/template_builder/area.vue b/app/javascript/template_builder/area.vue index 5f20ca2a..1076a232 100644 --- a/app/javascript/template_builder/area.vue +++ b/app/javascript/template_builder/area.vue @@ -63,6 +63,9 @@ v-if="field.type !== 'checkbox' || field.name" ref="name" :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" class="pr-1 cursor-text block focus:ring-1 focus:ring-base-content/40 focus:rounded-sm" style="min-width: 2px" @@ -300,7 +303,7 @@ v-else ref="defaultValue" :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 }" :placeholder="withFieldPlaceholder && !isValueInput ? defaultField?.title || field.title || field.name || defaultName : (field.type === 'date' ? field.preferences?.format || t('type_value') : t('type_value'))" @blur="onDefaultValueBlur" diff --git a/app/javascript/template_builder/contenteditable.vue b/app/javascript/template_builder/contenteditable.vue index c17724fb..ed7d9135 100644 --- a/app/javascript/template_builder/contenteditable.vue +++ b/app/javascript/template_builder/contenteditable.vue @@ -11,7 +11,7 @@ :data-empty="isEmpty" :style="{ minWidth }" :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" @keydown.enter.prevent="blurContenteditable" @input="updateInputValue" diff --git a/app/javascript/template_builder/i18n.js b/app/javascript/template_builder/i18n.js index eb90e989..3d80f52c 100644 --- a/app/javascript/template_builder/i18n.js +++ b/app/javascript/template_builder/i18n.js @@ -211,7 +211,8 @@ const en = { align_bottom: 'Align Bottom', fields_selected: '{count} Fields Selected', field_added: '{count} Field Added', - fields_added: '{count} Fields Added' + fields_added: '{count} Fields Added', + field_name: 'Field name' } const es = { diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb index fb7b7db1..d99b2c8d 100644 --- a/app/views/shared/_navbar.html.erb +++ b/app/views/shared/_navbar.html.erb @@ -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' %> <% end %> + + <% else %>