Implement Sprint 8: template builder keyboard accessibility (WCAG 2.1 AA)

- 8-A: Keyboard alternative for drag-and-drop field placement
  - Default field items: tabindex/role=button + Enter/Space to emit add-default-field
  - Field type grid buttons: keyboard click (event.detail===0) adds field directly (skips draw mode)
  - builder.vue: addDefaultField() handles keyboard-triggered field insertion + announces via aria-live
- 8-B: Context menu keyboard trigger on placed field areas
  - area.vue: tabindex=0 + areaLabel computed + onAreaKeydown handler
  - ContextMenu key / Shift+F10 synthesizes MouseEvent('contextmenu') at element center
- 8-C: Field settings dropdown focus trap
  - label @focus renders dropdown for keyboard users
  - @keydown.escape.stop closes dropdown
- 8-D: Live region announcements for field add/remove
  - announcePolite() called in addField(), addDefaultField() (builder.vue), and removeField() (fields.vue)
  - i18n: field_type_added + field_removed keys added in all 7 languages (en/es/it/pt/fr/de/nl)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
pull/599/head
Marcelo Paiva 3 weeks ago
parent 1aeea07910
commit 995da6ab0e

@ -3,9 +3,12 @@
class="absolute overflow-visible group field-area-container" class="absolute overflow-visible group field-area-container"
:style="positionStyle" :style="positionStyle"
:class="{ 'z-[1]': isMoved || isDragged }" :class="{ 'z-[1]': isMoved || isDragged }"
tabindex="0"
:aria-label="areaLabel"
@pointerdown.stop @pointerdown.stop
@mousedown="startMouseMove" @mousedown="startMouseMove"
@touchstart="startTouchDrag" @touchstart="startTouchDrag"
@keydown="onAreaKeydown"
> >
<div <div
v-if="isSelected || isDraw || isInMultiSelection" v-if="isSelected || isDraw || isInMultiSelection"
@ -602,6 +605,12 @@ export default {
defaultName () { defaultName () {
return this.buildDefaultName(this.field) return this.buildDefaultName(this.field)
}, },
areaLabel () {
const typeName = this.fieldNames[this.field.type] || this.field.type
const name = (this.defaultField ? (this.defaultField.title || this.field.title || this.field.name) : this.field.name) || this.defaultName
return `${typeName}: ${name}`
},
fontClasses () { fontClasses () {
if (!this.field.preferences) { if (!this.field.preferences) {
return { 'items-center': true } return { 'items-center': true }
@ -709,6 +718,22 @@ export default {
}, },
methods: { methods: {
buildDefaultName: Field.methods.buildDefaultName, buildDefaultName: Field.methods.buildDefaultName,
onAreaKeydown (event) {
if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10')) {
event.preventDefault()
event.stopPropagation()
const rect = this.$el.getBoundingClientRect()
const contextEvent = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
clientX: Math.round(rect.left + rect.width / 2),
clientY: Math.round(rect.top + rect.height / 2)
})
this.$el.dispatchEvent(contextEvent)
}
},
closeDropdown () { closeDropdown () {
this.$el.getRootNode().activeElement.blur() this.$el.getRootNode().activeElement.blur()
}, },

@ -496,6 +496,7 @@
:editable="editable" :editable="editable"
:show-tour-start-form="showTourStartForm" :show-tour-start-form="showTourStartForm"
@add-field="addField" @add-field="addField"
@add-default-field="addDefaultField"
@set-draw="[drawField = $event.field, drawOption = $event.option]" @set-draw="[drawField = $event.field, drawOption = $event.option]"
@select-submitter="selectedSubmitter = $event" @select-submitter="selectedSubmitter = $event"
@set-draw-type="[drawFieldType = $event, showDrawField = true]" @set-draw-type="[drawFieldType = $event, showDrawField = true]"
@ -594,6 +595,7 @@ import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, Ic
import { v4 } from 'uuid' import { v4 } from 'uuid'
import { ref, computed, toRaw } from 'vue' import { ref, computed, toRaw } from 'vue'
import * as i18n from './i18n' import * as i18n from './i18n'
import { announcePolite } from '../elements/aria_announce'
export default { export default {
name: 'TemplateBuilder', name: 'TemplateBuilder',
@ -1471,6 +1473,37 @@ export default {
this.insertField(field) this.insertField(field)
this.save() this.save()
announcePolite(this.t('field_type_added').replace('{type}', this.t(field.type || type)))
},
addDefaultField (defaultFieldItem) {
const type = defaultFieldItem.type
const field = {
name: defaultFieldItem.name || '',
uuid: v4(),
required: type !== 'checkbox',
areas: [],
submitter_uuid: this.selectedSubmitter.uuid,
type
}
if (['select', 'multiple', 'radio'].includes(type)) {
field.options = defaultFieldItem.options?.map((o) => ({ value: o.value ?? o, uuid: v4() })) || [{ value: '', uuid: v4() }, { value: '', uuid: v4() }]
}
if (type === 'stamp') {
field.readonly = true
}
if (type === 'date') {
field.preferences = { format: this.defaultDateFormat }
}
this.insertField(field)
this.save()
announcePolite(this.t('field_type_added').replace('{type}', this.t(type)))
}, },
startFieldDraw ({ name, type }) { startFieldDraw ({ name, type }) {
const existingField = this.template.fields?.find((f) => f.submitter_uuid === this.selectedSubmitter.uuid && name && name === f.name) const existingField = this.template.fields?.find((f) => f.submitter_uuid === this.selectedSubmitter.uuid && name && name === f.name)

@ -107,12 +107,14 @@
class="dropdown dropdown-end field-settings-dropdown" class="dropdown dropdown-end field-settings-dropdown"
@mouseenter="renderDropdown = true" @mouseenter="renderDropdown = true"
@touchstart="renderDropdown = true" @touchstart="renderDropdown = true"
@keydown.escape.stop="closeDropdown"
> >
<label <label
tabindex="0" tabindex="0"
:title="t('settings')" :title="t('settings')"
:aria-label="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"
@focus="renderDropdown = true"
> >
<IconSettings <IconSettings
:width="18" :width="18"

@ -81,9 +81,14 @@
<div <div
:style="{ backgroundColor }" :style="{ backgroundColor }"
draggable="true" draggable="true"
tabindex="0"
role="button"
:aria-label="field.title || field.name"
class="border border-base-300 rounded relative group mb-2 default-field fields-list-item" class="border border-base-300 rounded relative group mb-2 default-field fields-list-item"
@dragstart="onDragstart($event, field)" @dragstart="onDragstart($event, field)"
@dragend="$emit('drag-end')" @dragend="$emit('drag-end')"
@keydown.enter.prevent="$emit('add-default-field', field)"
@keydown.space.prevent="$emit('add-default-field', field)"
> >
<div class="flex items-center justify-between relative cursor-grab"> <div class="flex items-center justify-between relative cursor-grab">
<div class="flex items-center p-1 space-x-1"> <div class="flex items-center p-1 space-x-1">
@ -213,7 +218,7 @@
:aria-pressed="drawFieldType === type" :aria-pressed="drawFieldType === type"
@dragstart="onDragstart($event, { type: type })" @dragstart="onDragstart($event, { type: type })"
@dragend="$emit('drag-end')" @dragend="$emit('drag-end')"
@click="['file', 'payment', 'verification', 'kba'].includes(type) ? $emit('add-field', type) : $emit('set-draw-type', type)" @click="onFieldTypeClick($event, type)"
> >
<div <div
aria-hidden="true" aria-hidden="true"
@ -367,6 +372,7 @@ import FieldSubmitter from './field_submitter'
import { IconLock, IconCirclePlus, IconInnerShadowTop, IconSparkles } from '@tabler/icons-vue' import { IconLock, IconCirclePlus, IconInnerShadowTop, IconSparkles } from '@tabler/icons-vue'
import IconDrag from './icon_drag' import IconDrag from './icon_drag'
import { v4 } from 'uuid' import { v4 } from 'uuid'
import { announcePolite } from '../elements/aria_announce'
export default { export default {
name: 'TemplateFields', name: 'TemplateFields',
@ -480,7 +486,7 @@ export default {
default: false default: false
} }
}, },
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-draw-custom-field', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder', 'select-submitter'], emits: ['add-field', 'add-default-field', 'set-draw', 'set-draw-type', 'set-draw-custom-field', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder', 'select-submitter'],
data () { data () {
return { return {
fieldPagesLoaded: null, fieldPagesLoaded: null,
@ -561,6 +567,13 @@ export default {
} }
}, },
methods: { methods: {
onFieldTypeClick (event, type) {
if (['file', 'payment', 'verification', 'kba'].includes(type) || event.detail === 0) {
this.$emit('add-field', type)
} else {
this.$emit('set-draw-type', type)
}
},
onDragstart (event, field) { onDragstart (event, field) {
this.removeDragOverlay(event) this.removeDragOverlay(event)
@ -799,6 +812,7 @@ export default {
if (save) { if (save) {
this.save() this.save()
announcePolite(this.t('field_removed'))
} }
}, },
removeCustomField (field) { removeCustomField (field) {

@ -214,7 +214,9 @@ const en = {
fields_selected: '{count} Fields Selected', fields_selected: '{count} Fields Selected',
field_added: '{count} Field Added', field_added: '{count} Field Added',
fields_added: '{count} Fields Added', fields_added: '{count} Fields Added',
field_name: 'Field name' field_name: 'Field name',
field_type_added: '{type} field added',
field_removed: 'Field removed'
} }
const es = { const es = {
@ -427,7 +429,9 @@ const es = {
align_bottom: 'Alinear abajo', align_bottom: 'Alinear abajo',
fields_selected: '{count} Campos Seleccionados', fields_selected: '{count} Campos Seleccionados',
field_added: '{count} Campo Añadido', field_added: '{count} Campo Añadido',
fields_added: '{count} Campos Añadidos' fields_added: '{count} Campos Añadidos',
field_type_added: 'Campo {type} añadido',
field_removed: 'Campo eliminado'
} }
const it = { const it = {
@ -640,7 +644,9 @@ const it = {
align_bottom: 'Allinea in basso', align_bottom: 'Allinea in basso',
fields_selected: '{count} Campi Selezionati', fields_selected: '{count} Campi Selezionati',
field_added: '{count} Campo Aggiunto', field_added: '{count} Campo Aggiunto',
fields_added: '{count} Campi Aggiunti' fields_added: '{count} Campi Aggiunti',
field_type_added: 'Campo {type} aggiunto',
field_removed: 'Campo rimosso'
} }
const pt = { const pt = {
@ -853,7 +859,9 @@ const pt = {
align_bottom: 'Alinhar à parte inferior', align_bottom: 'Alinhar à parte inferior',
fields_selected: '{count} Campos Selecionados', fields_selected: '{count} Campos Selecionados',
field_added: '{count} Campo Adicionado', field_added: '{count} Campo Adicionado',
fields_added: '{count} Campos Adicionados' fields_added: '{count} Campos Adicionados',
field_type_added: 'Campo {type} adicionado',
field_removed: 'Campo removido'
} }
const fr = { const fr = {
@ -1066,7 +1074,9 @@ const fr = {
align_bottom: 'Aligner en bas', align_bottom: 'Aligner en bas',
fields_selected: '{count} Champs Sélectionnés', fields_selected: '{count} Champs Sélectionnés',
field_added: '{count} Champ Ajouté', field_added: '{count} Champ Ajouté',
fields_added: '{count} Champs Ajoutés' fields_added: '{count} Champs Ajoutés',
field_type_added: 'Champ {type} ajouté',
field_removed: 'Champ supprimé'
} }
const de = { const de = {
@ -1279,7 +1289,9 @@ const de = {
align_bottom: 'Unten ausrichten', align_bottom: 'Unten ausrichten',
fields_selected: '{count} Felder Ausgewählt', fields_selected: '{count} Felder Ausgewählt',
field_added: '{count} Feld Hinzugefügt', field_added: '{count} Feld Hinzugefügt',
fields_added: '{count} Felder Hinzugefügt' fields_added: '{count} Felder Hinzugefügt',
field_type_added: 'Feld {type} hinzugefügt',
field_removed: 'Feld entfernt'
} }
const nl = { const nl = {
@ -1492,7 +1504,9 @@ const nl = {
align_bottom: 'Onder uitlijnen', align_bottom: 'Onder uitlijnen',
fields_selected: '{count} Velden Geselecteerd', fields_selected: '{count} Velden Geselecteerd',
field_added: '{count} Veld Toegevoegd', field_added: '{count} Veld Toegevoegd',
fields_added: '{count} Velden Toegevoegd' fields_added: '{count} Velden Toegevoegd',
field_type_added: 'Veld {type} toegevoegd',
field_removed: 'Veld verwijderd'
} }
export { en, es, it, pt, fr, de, nl } export { en, es, it, pt, fr, de, nl }

Loading…
Cancel
Save