diff --git a/app/javascript/elements/aria_announce.js b/app/javascript/elements/aria_announce.js new file mode 100644 index 00000000..8f36fce1 --- /dev/null +++ b/app/javascript/elements/aria_announce.js @@ -0,0 +1,18 @@ +export function announceError (message, timeout = 7000) { + const el = document.createElement('div') + el.setAttribute('role', 'alert') + el.setAttribute('aria-live', 'assertive') + el.className = 'sr-only' + el.textContent = message + document.body.append(el) + setTimeout(() => el.remove(), timeout) +} + +export function announcePolite (message, timeout = 5000) { + const el = document.createElement('div') + el.setAttribute('aria-live', 'polite') + el.className = 'sr-only' + el.textContent = message + document.body.append(el) + setTimeout(() => el.remove(), timeout) +} diff --git a/app/javascript/elements/clipboard_copy.js b/app/javascript/elements/clipboard_copy.js index b31808a6..50a8ee3a 100644 --- a/app/javascript/elements/clipboard_copy.js +++ b/app/javascript/elements/clipboard_copy.js @@ -1,3 +1,5 @@ +import { announceError } from './aria_announce' + export default class extends HTMLElement { connectedCallback () { this.clearChecked() @@ -17,7 +19,7 @@ export default class extends HTMLElement { navigator.clipboard.writeText(text) } else { if (e.target.tagName !== 'INPUT') { - alert(`Clipboard not available. Make sure you're using https://\nCopy text: ${text}`) + announceError(`Clipboard not available. Please use HTTPS. Copy text: ${text}`) } } } diff --git a/app/javascript/elements/download_button.js b/app/javascript/elements/download_button.js index 26aa1e8a..828269b7 100644 --- a/app/javascript/elements/download_button.js +++ b/app/javascript/elements/download_button.js @@ -1,4 +1,5 @@ import { target, targetable } from '@github/catalyst/lib/targetable' +import { announceError } from './aria_announce' export default targetable(class extends HTMLElement { static [target.static] = ['defaultButton', 'loadingButton'] @@ -45,7 +46,8 @@ export default targetable(class extends HTMLElement { this.downloadUrls(urls) } } else { - alert('Failed to download files') + announceError('Failed to download files') + this.toggleState() } }) } diff --git a/app/javascript/elements/fetch_form.js b/app/javascript/elements/fetch_form.js index b3378a63..32ebf915 100644 --- a/app/javascript/elements/fetch_form.js +++ b/app/javascript/elements/fetch_form.js @@ -1,3 +1,5 @@ +import { announceError } from './aria_announce' + export default class extends HTMLElement { connectedCallback () { this.form.addEventListener('submit', (e) => { @@ -21,7 +23,7 @@ export default class extends HTMLElement { const data = JSON.parse(await resp.text()) if (data.error) { - alert(data.error) + announceError(data.error) } } catch (err) { console.error(err) diff --git a/app/javascript/elements/prompt_password.js b/app/javascript/elements/prompt_password.js index 48561962..f9beb9fc 100644 --- a/app/javascript/elements/prompt_password.js +++ b/app/javascript/elements/prompt_password.js @@ -1,16 +1,68 @@ export default class extends HTMLElement { connectedCallback () { - const input = document.createElement('input') + this.showDialog() + } + + showDialog () { + const dialogId = `prompt-password-dialog-${Math.random().toString(36).slice(2)}` + const inputId = `prompt-password-input-${Math.random().toString(36).slice(2)}` + + const dialog = document.createElement('div') + dialog.setAttribute('role', 'dialog') + dialog.setAttribute('aria-modal', 'true') + dialog.setAttribute('aria-labelledby', `${dialogId}-title`) + dialog.id = dialogId + dialog.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black/50' + + dialog.innerHTML = ` +
+

PDF Password Required

+
+ + +
+
+ + +
+
+ ` + + document.body.append(dialog) + + const input = dialog.querySelector(`#${inputId}`) + const cancelBtn = dialog.querySelector('[data-action="cancel"]') + const confirmBtn = dialog.querySelector('[data-action="confirm"]') + + requestAnimationFrame(() => input.focus()) + + const confirm = () => { + const passwordInput = document.createElement('input') + passwordInput.type = 'hidden' + passwordInput.name = 'password' + passwordInput.value = input.value + this.form.append(passwordInput) + dialog.remove() + this.form.requestSubmit() + this.remove() + } - input.type = 'hidden' - input.name = 'password' - input.value = prompt('Enter PDF password') + const cancel = () => { + dialog.remove() + this.remove() + } - this.form.append(input) + confirmBtn.addEventListener('click', confirm) + cancelBtn.addEventListener('click', cancel) - this.form.requestSubmit() + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') confirm() + if (e.key === 'Escape') cancel() + }) - this.remove() + dialog.addEventListener('keydown', (e) => { + if (e.key === 'Escape') cancel() + }) } get form () { diff --git a/app/javascript/elements/turbo_modal.js b/app/javascript/elements/turbo_modal.js index 69c20549..261976c5 100644 --- a/app/javascript/elements/turbo_modal.js +++ b/app/javascript/elements/turbo_modal.js @@ -1,24 +1,39 @@ import { actionable } from '@github/catalyst/lib/actionable' +const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + export default actionable(class extends HTMLElement { connectedCallback () { document.body.classList.add('overflow-hidden') - document.addEventListener('keyup', this.onEscKey) + this.setAttribute('role', 'dialog') + this.setAttribute('aria-modal', 'true') + + this._previousFocus = document.activeElement + document.addEventListener('keyup', this.onEscKey) + document.addEventListener('keydown', this.onTabKey) document.addEventListener('turbo:before-cache', this.close) if (this.dataset.closeAfterSubmit !== 'false') { document.addEventListener('turbo:submit-end', this.onSubmit) } + + requestAnimationFrame(() => { + const first = this.querySelector(FOCUSABLE) + first?.focus() + }) } disconnectedCallback () { document.body.classList.remove('overflow-hidden') document.removeEventListener('keyup', this.onEscKey) + document.removeEventListener('keydown', this.onTabKey) document.removeEventListener('turbo:submit-end', this.onSubmit) document.removeEventListener('turbo:before-cache', this.close) + + this._previousFocus?.focus() } onSubmit = (e) => { @@ -33,6 +48,28 @@ export default actionable(class extends HTMLElement { } } + onTabKey = (e) => { + if (e.key !== 'Tab') return + + const focusable = Array.from(this.querySelectorAll(FOCUSABLE)) + if (focusable.length === 0) return + + const first = focusable[0] + const last = focusable[focusable.length - 1] + + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault() + last.focus() + } + } else { + if (document.activeElement === last) { + e.preventDefault() + first.focus() + } + } + } + close = (e) => { e?.preventDefault() diff --git a/app/javascript/submission_form/area.vue b/app/javascript/submission_form/area.vue index 8f323f0b..b158b564 100644 --- a/app/javascript/submission_form/area.vue +++ b/app/javascript/submission_form/area.vue @@ -134,6 +134,7 @@ v-if="submittable" type="checkbox" :value="false" + :aria-label="field.title || field.name || fieldNames[field.type]" class="aspect-square base-checkbox" :class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }" :checked="!!modelValue" @@ -154,6 +155,7 @@ v-if="submittable" type="radio" :value="false" + :aria-label="(field.title || field.name || fieldNames[field.type]) + (option?.value ? ': ' + option.value : '')" class="aspect-square checked:checkbox checked:checkbox-xs" :class="{ 'base-radio': !modelValue || modelValue !== optionValue(option), '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }" :checked="!!modelValue && modelValue === optionValue(option)" @@ -208,7 +210,7 @@ > {{ field.name }} {{ t('select_your_option') }} diff --git a/app/javascript/submission_form/phone_step.vue b/app/javascript/submission_form/phone_step.vue index b854c384..f7d22e45 100644 --- a/app/javascript/submission_form/phone_step.vue +++ b/app/javascript/submission_form/phone_step.vue @@ -102,7 +102,7 @@ ref="phone" :value="defaultValue && detectedPhoneValueDialCode ? phoneValue.split('+' + detectedPhoneValueDialCode).slice(-1).join('') : phoneValue" :readonly="!!defaultValue" - class="base-input !text-2xl !rounded-l-none !border-l-0 !outline-none w-full" + class="base-input !text-2xl !rounded-l-none !border-l-0 w-full" autocomplete="tel" type="tel" inputmode="tel" diff --git a/app/javascript/submission_form/signature_step.vue b/app/javascript/submission_form/signature_step.vue index 1d1ac6d6..c77e6c70 100644 --- a/app/javascript/submission_form/signature_step.vue +++ b/app/javascript/submission_form/signature_step.vue @@ -205,6 +205,7 @@ + @@ -77,7 +77,7 @@
+ - +