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 @@
+