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"
:style="positionStyle"
:class="{ 'z-[1]': isMoved || isDragged }"
tabindex="0"
:aria-label="areaLabel"
@pointerdown.stop
@mousedown="startMouseMove"
@touchstart="startTouchDrag"
@keydown="onAreaKeydown"
>
<div
v-if="isSelected || isDraw || isInMultiSelection"
@ -602,6 +605,12 @@ export default {
defaultName () {
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 () {
if (!this.field.preferences) {
return { 'items-center': true }
@ -709,6 +718,22 @@ export default {
},
methods: {
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 () {
this.$el.getRootNode().activeElement.blur()
},

@ -496,6 +496,7 @@
:editable="editable"
:show-tour-start-form="showTourStartForm"
@add-field="addField"
@add-default-field="addDefaultField"
@set-draw="[drawField = $event.field, drawOption = $event.option]"
@select-submitter="selectedSubmitter = $event"
@set-draw-type="[drawFieldType = $event, showDrawField = true]"
@ -594,6 +595,7 @@ import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, Ic
import { v4 } from 'uuid'
import { ref, computed, toRaw } from 'vue'
import * as i18n from './i18n'
import { announcePolite } from '../elements/aria_announce'
export default {
name: 'TemplateBuilder',
@ -1471,6 +1473,37 @@ export default {
this.insertField(field)
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 }) {
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"
@mouseenter="renderDropdown = true"
@touchstart="renderDropdown = true"
@keydown.escape.stop="closeDropdown"
>
<label
tabindex="0"
:title="t('settings')"
:aria-label="t('settings')"
class="cursor-pointer text-transparent group-hover:text-base-content"
@focus="renderDropdown = true"
>
<IconSettings
:width="18"

@ -81,9 +81,14 @@
<div
:style="{ backgroundColor }"
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"
@dragstart="onDragstart($event, field)"
@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 p-1 space-x-1">
@ -213,7 +218,7 @@
:aria-pressed="drawFieldType === type"
@dragstart="onDragstart($event, { type: type })"
@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
aria-hidden="true"
@ -367,6 +372,7 @@ import FieldSubmitter from './field_submitter'
import { IconLock, IconCirclePlus, IconInnerShadowTop, IconSparkles } from '@tabler/icons-vue'
import IconDrag from './icon_drag'
import { v4 } from 'uuid'
import { announcePolite } from '../elements/aria_announce'
export default {
name: 'TemplateFields',
@ -480,7 +486,7 @@ export default {
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 () {
return {
fieldPagesLoaded: null,
@ -561,6 +567,13 @@ export default {
}
},
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) {
this.removeDragOverlay(event)
@ -799,6 +812,7 @@ export default {
if (save) {
this.save()
announcePolite(this.t('field_removed'))
}
},
removeCustomField (field) {

@ -214,7 +214,9 @@ const en = {
fields_selected: '{count} Fields Selected',
field_added: '{count} Field 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 = {
@ -427,7 +429,9 @@ const es = {
align_bottom: 'Alinear abajo',
fields_selected: '{count} Campos Seleccionados',
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 = {
@ -640,7 +644,9 @@ const it = {
align_bottom: 'Allinea in basso',
fields_selected: '{count} Campi Selezionati',
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 = {
@ -853,7 +859,9 @@ const pt = {
align_bottom: 'Alinhar à parte inferior',
fields_selected: '{count} Campos Selecionados',
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 = {
@ -1066,7 +1074,9 @@ const fr = {
align_bottom: 'Aligner en bas',
fields_selected: '{count} Champs Sélectionnés',
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 = {
@ -1279,7 +1289,9 @@ const de = {
align_bottom: 'Unten ausrichten',
fields_selected: '{count} Felder Ausgewählt',
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 = {
@ -1492,7 +1504,9 @@ const nl = {
align_bottom: 'Onder uitlijnen',
fields_selected: '{count} Velden Geselecteerd',
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 }

Loading…
Cancel
Save