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', wait_countdown_seconds: 'Wait {countdown} seconds',
signature_drawing_pad: 'Signature drawing pad. Use the tools above to draw or type your signature.', 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.', 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 = { const es = {

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

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

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

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

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

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

@ -24,6 +24,7 @@
<div> <div>
<button <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" 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" @click.stop="isShowConditionsModal = true"
> >
<IconRouteAltLeft <IconRouteAltLeft
@ -64,6 +65,7 @@
> >
<button <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" 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)" @click.stop="$emit('reorder', item)"
> >
<IconSortDescending2 <IconSortDescending2

@ -8,19 +8,34 @@
<div class="grid md:grid-cols-2 gap-4"> <div class="grid md:grid-cols-2 gap-4">
<div class="form-control"> <div class="form-control">
<%= f.label :first_name, t('first_name'), class: 'label' %> <%= 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>
<div class="form-control"> <div class="form-control">
<%= f.label :last_name, t('last_name'), class: 'label' %> <%= 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> </div>
<div class="form-control"> <div class="form-control">
<%= f.label :email, t('email'), class: 'label' %> <%= 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?) %> <% if current_user.try(:pending_reconfirmation?) %>
<label class="label"> <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> </label>
<% end %> <% end %>
</div> </div>
@ -61,17 +76,32 @@
</p> </p>
<%= form_for current_user, url: update_password_settings_profile_index_path, method: :patch, html: { autocomplete: 'off' } do |f| %> <%= 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.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="<%= 'peer-invalid:hidden' if current_user.errors.blank? %> space-y-4 mt-4">
<div class="form-control"> <div class="form-control">
<%= f.label :password_confirmation, t('confirm_password'), class: 'label' %> <%= 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>
<div class="form-control"> <div class="form-control">
<%= f.label :current_password, t('current_password'), class: 'label' %> <%= 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) %> <% 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') %> <%= t('dont_remember_your_current_password_click_here_to_reset_it_html') %>
</span> </span>
<% end %> <% end %>

@ -158,9 +158,10 @@
<% submitter_index = submitters_order_index[submitter.uuid] %> <% submitter_index = submitters_order_index[submitter.uuid] %>
<% bg_class = bg_classes[submitter_index % bg_classes.size] %> <% 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="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') %> <%= svg_icon(SubmissionsController::FIELD_ICONS.fetch(field['type'], 'text_size'), class: 'max-h-10 w-full h-full stroke-2 opacity-50') %>
</div> </div>
<span class="sr-only"><%= field['name'].presence || field['type'] %> — <%= submitter&.name.presence || t('awaiting_signature') %></span>
</div> </div>
<% end %> <% end %>
<% end %> <% end %>
@ -199,7 +200,7 @@
<div class="group border border-base-300 rounded-md px-2 py-1 mb-1"> <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 justify-between">
<div class="flex items-center space-x-1"> <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"> <span class="text-lg" dir="auto">
<%= (@submission.template_submitters || @submission.template.submitters).find { |e| e['uuid'] == submitter&.uuid }&.dig('name') || "#{(index + 1).ordinalize} Submitter" %> <%= (@submission.template_submitters || @submission.template.submitters).find { |e| e['uuid'] == submitter&.uuid }&.dig('name') || "#{(index + 1).ordinalize} Submitter" %>
</span> </span>

Loading…
Cancel
Save