Fix WCAG 2.1 AA medium issues: color-only indicators, form errors, icon-only buttons

- M1: submissions/show.html.erb — aria-hidden on decorative color dot; sr-only text on
  colored field overlays (field name + submitter) for screen reader users (WCAG 1.4.1)
- M2: field_submitter.vue — aria-hidden on all decorative color dots; aria-label on
  compact mode label (selectedSubmitter name); changed inner button to span (WCAG 1.4.1)
- M8: profile/index.html.erb — inline validation error messages (role="alert") with
  aria-describedby + aria-invalid on all profile and password form fields (WCAG 3.3.1)
- M10: Add aria-label to icon-only buttons/links across 7 files:
  - field.vue: draw, formula, condition, settings, remove, draw-option buttons
  - preview.vue: document condition and reorder buttons
  - signature_step.vue: QR toggle (with aria-pressed) and close QR buttons
  - text_step.vue: toggle multiline text button
  - import_list.vue: preview column data info button
- Add i18n keys: show_qr_code, close_qr_code (submission_form); preview_column_data (template_builder)

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

@ -100,7 +100,9 @@ const en = {
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.'
qr_code_for_mobile_signature: 'QR code for signing on a mobile device.',
show_qr_code: 'Show QR code for mobile signing',
close_qr_code: 'Close QR code'
}
const es = {

@ -93,6 +93,8 @@
href="#"
class="btn btn-sm btn-neutral font-medium hidden md:flex"
:class="{ 'btn-outline': !isShowQr, 'text-white': isShowQr }"
:aria-label="isShowQr ? t('close_qr_code') : t('show_qr_code')"
:aria-pressed="isShowQr ? 'true' : 'false'"
@click.prevent="isShowQr ? hideQr() : [isTextSignature = false, showQr()]"
>
<IconQrcode
@ -187,6 +189,7 @@
<a
href="#"
class="btn btn-sm btn-circle btn-normal btn-outline"
:aria-label="t('close_qr_code')"
@click.prevent="hideQr"
>
<IconX />

@ -68,6 +68,7 @@
<a
href="#"
class="btn btn-ghost btn-circle btn-sm toggle-multiline-text-button"
:aria-label="t('toggle_multiline_text')"
@click.prevent="toggleTextArea"
>
<IconAlignBoxLeftTop />

@ -59,6 +59,7 @@
<button
v-if="field && !field.areas?.length"
:title="t('draw')"
:aria-label="t('draw')"
class="relative cursor-pointer text-transparent group-hover:text-base-content"
@click="$emit('set-draw', { field })"
>
@ -71,6 +72,7 @@
v-if="field.preferences?.formula"
class="relative cursor-pointer text-transparent group-hover:text-base-content"
:title="t('formula')"
:aria-label="t('formula')"
@click="isShowFormulaModal = true"
>
<IconMathFunction
@ -82,6 +84,7 @@
v-if="field.conditions?.length"
class="relative cursor-pointer text-transparent group-hover:text-base-content"
:title="t('condition')"
:aria-label="t('condition')"
@click="isShowConditionsModal = true"
>
<IconRouteAltLeft
@ -108,6 +111,7 @@
<label
tabindex="0"
:title="t('settings')"
:aria-label="t('settings')"
class="cursor-pointer text-transparent group-hover:text-base-content"
>
<IconSettings
@ -147,6 +151,7 @@
<button
class="relative text-transparent group-hover:text-base-content pr-1 field-remove-button"
:title="t('remove')"
:aria-label="t('remove')"
@click="$emit('remove', field)"
>
<IconTrashX
@ -197,6 +202,7 @@
>
<button
:title="t('draw')"
:aria-label="t('draw')"
tabindex="-1"
@click.prevent="$emit('set-draw', { field, option })"
>

@ -10,6 +10,7 @@
<span
class="w-3 h-3 flex-shrink-0 rounded-full"
:class="colors[submitters.indexOf(selectedSubmitter) % colors.length]"
aria-hidden="true"
/>
<Contenteditable
v-model="selectedSubmitter.name"
@ -54,6 +55,7 @@
<span
class="rounded-full w-3 h-3 ml-1 mr-3 flex-shrink-0"
:class="colors[index % colors.length]"
aria-hidden="true"
/>
<span>
{{ submitter.name }}
@ -97,11 +99,13 @@
v-if="compact"
tabindex="0"
:title="selectedSubmitter?.name"
:aria-label="selectedSubmitter?.name"
class="cursor-pointer text-base-100 flex h-full items-center justify-center"
>
<button
<span
class="mx-1 w-3 h-3 rounded-full flex-shrink-0"
:class="colors[submitters.indexOf(selectedSubmitter) % colors.length]"
aria-hidden="true"
/>
</label>
<label
@ -114,6 +118,7 @@
<span
class="w-3 h-3 rounded-full flex-shrink-0"
:class="colors[submitters.indexOf(selectedSubmitter) % colors.length]"
aria-hidden="true"
/>
<Contenteditable
v-model="selectedSubmitter.name"
@ -155,6 +160,7 @@
<span
class="rounded-full w-3 h-3 ml-1 mr-3 flex-shrink-0"
:class="colors[index % colors.length]"
aria-hidden="true"
/>
<span>
{{ submitter.name }}

@ -107,6 +107,7 @@ const en = {
option: 'Option',
options: 'Options',
condition: 'Condition',
preview_column_data: 'Preview column data',
first_party: 'First Party',
second_party: 'Second Party',
third_party: 'Third Party',

@ -113,6 +113,7 @@
>
<button
class="btn btn-xs btn-circle bg-white border-0 border-gray-300"
:aria-label="t('preview_column_data')"
@click.prevent
>
<IconInfoCircle class="h-4 w-4" />

@ -24,6 +24,7 @@
<div>
<button
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors p-0 document-control-button"
:aria-label="t('condition')"
@click.stop="isShowConditionsModal = true"
>
<IconRouteAltLeft
@ -64,6 +65,7 @@
>
<button
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors p-0 document-control-button"
:aria-label="t('reorder_fields')"
@click.stop="$emit('reorder', item)"
>
<IconSortDescending2

@ -8,19 +8,34 @@
<div class="grid md:grid-cols-2 gap-4">
<div class="form-control">
<%= f.label :first_name, t('first_name'), class: 'label' %>
<%= f.text_field :first_name, required: true, class: 'base-input', dir: 'auto' %>
<%= f.text_field :first_name, required: true, class: 'base-input', dir: 'auto',
'aria-describedby': (f.object.errors[:first_name].any? ? 'profile_first_name_error' : nil),
'aria-invalid': (f.object.errors[:first_name].any? ? 'true' : nil) %>
<% if f.object.errors[:first_name].any? %>
<p id="profile_first_name_error" class="text-sm text-error mt-1" role="alert"><%= f.object.errors[:first_name].first %></p>
<% end %>
</div>
<div class="form-control">
<%= f.label :last_name, t('last_name'), class: 'label' %>
<%= f.text_field :last_name, required: false, class: 'base-input', dir: 'auto' %>
<%= f.text_field :last_name, required: false, class: 'base-input', dir: 'auto',
'aria-describedby': (f.object.errors[:last_name].any? ? 'profile_last_name_error' : nil),
'aria-invalid': (f.object.errors[:last_name].any? ? 'true' : nil) %>
<% if f.object.errors[:last_name].any? %>
<p id="profile_last_name_error" class="text-sm text-error mt-1" role="alert"><%= f.object.errors[:last_name].first %></p>
<% end %>
</div>
</div>
<div class="form-control">
<%= f.label :email, t('email'), class: 'label' %>
<%= f.email_field :email, autocomplete: 'off', class: 'base-input' %>
<%= f.email_field :email, autocomplete: 'off', class: 'base-input',
'aria-describedby': [('profile_email_error' if f.object.errors[:email].any?), ('profile_email_pending' if current_user.try(:pending_reconfirmation?))].compact.join(' ').presence,
'aria-invalid': (f.object.errors[:email].any? ? 'true' : nil) %>
<% if f.object.errors[:email].any? %>
<p id="profile_email_error" class="text-sm text-error mt-1" role="alert"><%= f.object.errors[:email].first %></p>
<% end %>
<% if current_user.try(:pending_reconfirmation?) %>
<label class="label">
<span class="label-text-alt"><%= t('email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm', email: f.object.unconfirmed_email) %></span>
<span id="profile_email_pending" class="label-text-alt"><%= t('email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm', email: f.object.unconfirmed_email) %></span>
</label>
<% end %>
</div>
@ -61,17 +76,32 @@
</p>
<%= form_for current_user, url: update_password_settings_profile_index_path, method: :patch, html: { autocomplete: 'off' } do |f| %>
<%= f.label :password, t('new_password'), class: 'label' %>
<%= f.password_field :password, autocomplete: 'off', class: 'base-input peer w-full', required: true %>
<%= f.password_field :password, autocomplete: 'off', class: 'base-input peer w-full', required: true,
'aria-describedby': (f.object.errors[:password].any? ? 'profile_password_error' : nil),
'aria-invalid': (f.object.errors[:password].any? ? 'true' : nil) %>
<% if f.object.errors[:password].any? %>
<p id="profile_password_error" class="text-sm text-error mt-1" role="alert"><%= f.object.errors[:password].first %></p>
<% end %>
<div class="<%= 'peer-invalid:hidden' if current_user.errors.blank? %> space-y-4 mt-4">
<div class="form-control">
<%= f.label :password_confirmation, t('confirm_password'), class: 'label' %>
<%= f.password_field :password_confirmation, autocomplete: 'off', class: 'base-input' %>
<%= f.password_field :password_confirmation, autocomplete: 'off', class: 'base-input',
'aria-describedby': (f.object.errors[:password_confirmation].any? ? 'profile_password_confirmation_error' : nil),
'aria-invalid': (f.object.errors[:password_confirmation].any? ? 'true' : nil) %>
<% if f.object.errors[:password_confirmation].any? %>
<p id="profile_password_confirmation_error" class="text-sm text-error mt-1" role="alert"><%= f.object.errors[:password_confirmation].first %></p>
<% end %>
</div>
<div class="form-control">
<%= f.label :current_password, t('current_password'), class: 'label' %>
<%= f.password_field :current_password, autocomplete: 'current-password', class: 'base-input' %>
<%= f.password_field :current_password, autocomplete: 'current-password', class: 'base-input',
'aria-describedby': [('profile_current_password_error' if f.object.errors[:current_password].any?), ('profile_password_hint' if Accounts.can_send_emails?(current_account))].compact.join(' ').presence,
'aria-invalid': (f.object.errors[:current_password].any? ? 'true' : nil) %>
<% if f.object.errors[:current_password].any? %>
<p id="profile_current_password_error" class="text-sm text-error mt-1" role="alert"><%= f.object.errors[:current_password].first %></p>
<% end %>
<% if Accounts.can_send_emails?(current_account) %>
<span class="label-text-alt mt-1">
<span id="profile_password_hint" class="label-text-alt mt-1">
<%= t('dont_remember_your_current_password_click_here_to_reset_it_html') %>
</span>
<% end %>

@ -158,9 +158,10 @@
<% submitter_index = submitters_order_index[submitter.uuid] %>
<% bg_class = bg_classes[submitter_index % bg_classes.size] %>
<div class="absolute overflow-visible" style="width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%;">
<div class="flex h-full w-full bg-opacity-80 justify-center items-center <%= bg_class %>">
<div class="flex h-full w-full bg-opacity-80 justify-center items-center <%= bg_class %>" aria-hidden="true">
<%= svg_icon(SubmissionsController::FIELD_ICONS.fetch(field['type'], 'text_size'), class: 'max-h-10 w-full h-full stroke-2 opacity-50') %>
</div>
<span class="sr-only"><%= field['name'].presence || field['type'] %> — <%= submitter&.name.presence || t('awaiting_signature') %></span>
</div>
<% end %>
<% end %>
@ -199,7 +200,7 @@
<div class="group border border-base-300 rounded-md px-2 py-1 mb-1">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-1">
<span class="mx-1 w-3 h-3 shrink-0 rounded-full <%= colors[index % 10] %>"></span>
<span class="mx-1 w-3 h-3 shrink-0 rounded-full <%= colors[index % 10] %>" aria-hidden="true"></span>
<span class="text-lg" dir="auto">
<%= (@submission.template_submitters || @submission.template.submitters).find { |e| e['uuid'] == submitter&.uuid }&.dig('name') || "#{(index + 1).ordinalize} Submitter" %>
</span>

Loading…
Cancel
Save