Make reorder buttons keyboard accessible (#3372)

* [CST-15595] add keyboard drag and drop functionality

* [CST-15595] add aria live messages

* [CST-15595] fix e2e tests

* [CST-15595] fix unit tests

* [CST-15595] improve drag and drop keyboard functionality

* [CST-15595] add keydown.enter for keyboard drag and drop

---------

Co-authored-by: Andrea Barbasso <´andrea.barbasso@4science.com´>
This commit is contained in:
AndreaBarbasso
2025-01-30 18:11:42 +01:00
committed by GitHub
parent e867993bb8
commit bdac58d7fc
5 changed files with 342 additions and 13 deletions

View File

@@ -5,9 +5,10 @@
[ngClass]="getClass('element', 'control')"> [ngClass]="getClass('element', 'control')">
<!-- Draggable Container --> <!-- Draggable Container -->
<div cdkDropList cdkDropListLockAxis="y" (cdkDropListDropped)="moveSelection($event)"> <div role="listbox" [attr.aria-label]="'dynamic-form-array.sortable-list.label' | translate" #dropList cdkDropList cdkDropListLockAxis="y" (cdkDropListDropped)="moveSelection($event)">
<!-- Draggable Items --> <!-- Draggable Items -->
<div *ngFor="let groupModel of model.groups" <div #sortableElement
*ngFor="let groupModel of model.groups; let idx = index; let length = count"
role="group" role="group"
[formGroupName]="groupModel.index" [formGroupName]="groupModel.index"
[ngClass]="[getClass('element', 'group'), getClass('grid', 'group')]" [ngClass]="[getClass('element', 'group'), getClass('grid', 'group')]"
@@ -16,7 +17,14 @@
[cdkDragPreviewClass]="'ds-submission-reorder-dragging'" [cdkDragPreviewClass]="'ds-submission-reorder-dragging'"
[class.grey-background]="model.isInlineGroupArray"> [class.grey-background]="model.isInlineGroupArray">
<!-- Item content --> <!-- Item content -->
<div class="drag-handle" [class.drag-disable]="dragDisabled" tabindex="0" cdkDragHandle> <div class="drag-handle" [class.drag-disable]="dragDisabled" tabindex="0" cdkDragHandle
(focus)="addInstructionMessageToLiveRegion(sortableElement)"
(keydown.space)="toggleKeyboardDragAndDrop($event, sortableElement, idx, length)"
(keydown.enter)="toggleKeyboardDragAndDrop($event, sortableElement, idx, length)"
(keydown.tab)="stopKeyboardDragAndDrop(sortableElement, idx, length)"
(keydown.escape)="cancelKeyboardDragAndDrop(sortableElement, idx, length)"
(keydown.arrowUp)="handleArrowPress($event, dropList, length, idx, 'up')"
(keydown.arrowDown)="handleArrowPress($event, dropList, length, idx, 'down')">
<i class="drag-icon fas fa-grip-vertical fa-fw" [class.drag-disable]="dragDisabled" ></i> <i class="drag-icon fas fa-grip-vertical fa-fw" [class.drag-disable]="dragDisabled" ></i>
</div> </div>
<ng-container *ngTemplateOutlet="startTemplate?.templateRef; context: groupModel"></ng-container> <ng-container *ngTemplateOutlet="startTemplate?.templateRef; context: groupModel"></ng-container>

View File

@@ -17,7 +17,6 @@
margin-right: calc(-0.5 * var(--bs-spacer)); margin-right: calc(-0.5 * var(--bs-spacer));
padding-right: calc(0.5 * var(--bs-spacer)); padding-right: calc(0.5 * var(--bs-spacer));
.drag-icon { .drag-icon {
visibility: hidden;
width: calc(2 * var(--bs-spacer)); width: calc(2 * var(--bs-spacer));
color: var(--bs-gray-600); color: var(--bs-gray-600);
margin: var(--bs-btn-padding-y) 0; margin: var(--bs-btn-padding-y) 0;
@@ -27,9 +26,6 @@
&:hover, &:focus { &:hover, &:focus {
cursor: grab; cursor: grab;
.drag-icon {
visibility: visible;
}
} }
} }
@@ -40,18 +36,12 @@
} }
&:focus { &:focus {
.drag-icon {
visibility: visible;
}
} }
} }
.cdk-drop-list-dragging { .cdk-drop-list-dragging {
.drag-handle { .drag-handle {
cursor: grabbing; cursor: grabbing;
.drag-icon {
visibility: hidden;
}
} }
} }
@@ -63,3 +53,9 @@
.cdk-drag-placeholder { .cdk-drag-placeholder {
opacity: 0; opacity: 0;
} }
::ng-deep {
.sorting-with-keyboard input {
background-color: var(--bs-gray-400);
}
}

View File

@@ -0,0 +1,159 @@
import { HttpClient } from '@angular/common/http';
import { EventEmitter } from '@angular/core';
import {
ComponentFixture,
inject,
TestBed,
} from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser';
import {
DYNAMIC_FORM_CONTROL_MAP_FN,
DynamicFormLayoutService,
DynamicFormService,
DynamicFormValidationService,
DynamicInputModel,
} from '@ng-dynamic-forms/core';
import { provideMockStore } from '@ngrx/store/testing';
import {
TranslateModule,
TranslateService,
} from '@ngx-translate/core';
import { NgxMaskModule } from 'ngx-mask';
import { of } from 'rxjs';
import {
APP_CONFIG,
APP_DATA_SERVICES_MAP,
} from '../../../../../../../config/app-config.interface';
import { environment } from '../../../../../../../environments/environment.test';
import { SubmissionService } from '../../../../../../submission/submission.service';
import { DsDynamicFormControlContainerComponent } from '../../ds-dynamic-form-control-container.component';
import { dsDynamicFormControlMapFn } from '../../ds-dynamic-form-control-map-fn';
import { DynamicRowArrayModel } from '../ds-dynamic-row-array-model';
import { DsDynamicFormArrayComponent } from './dynamic-form-array.component';
describe('DsDynamicFormArrayComponent', () => {
const translateServiceStub = {
get: () => of('translated-text'),
instant: () => 'translated-text',
onLangChange: new EventEmitter(),
onTranslationChange: new EventEmitter(),
onDefaultLangChange: new EventEmitter(),
};
let component: DsDynamicFormArrayComponent;
let fixture: ComponentFixture<DsDynamicFormArrayComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
ReactiveFormsModule,
DsDynamicFormArrayComponent,
NgxMaskModule.forRoot(),
TranslateModule.forRoot(),
],
providers: [
DynamicFormLayoutService,
DynamicFormValidationService,
provideMockStore(),
{ provide: APP_DATA_SERVICES_MAP, useValue: {} },
{ provide: TranslateService, useValue: translateServiceStub },
{ provide: HttpClient, useValue: {} },
{ provide: SubmissionService, useValue: {} },
{ provide: APP_CONFIG, useValue: environment },
{ provide: DYNAMIC_FORM_CONTROL_MAP_FN, useValue: dsDynamicFormControlMapFn },
],
}).overrideComponent(DsDynamicFormArrayComponent, {
remove: {
imports: [DsDynamicFormControlContainerComponent],
},
})
.compileComponents();
});
beforeEach(inject([DynamicFormService], (service: DynamicFormService) => {
const formModel = [
new DynamicRowArrayModel({
id: 'testFormRowArray',
initialCount: 5,
notRepeatable: false,
relationshipConfig: undefined,
submissionId: '1234',
isDraggable: true,
groupFactory: () => {
return [
new DynamicInputModel({ id: 'testFormRowArrayGroupInput' }),
];
},
required: false,
metadataKey: 'dc.contributor.author',
metadataFields: ['dc.contributor.author'],
hasSelectableMetadata: true,
showButtons: true,
typeBindRelations: [{ match: 'VISIBLE', operator: 'OR', when: [{ id: 'dc.type', value: 'Book' }] }],
}),
];
fixture = TestBed.createComponent(DsDynamicFormArrayComponent);
component = fixture.componentInstance;
component.model = formModel[0] as DynamicRowArrayModel;
component.group = service.createFormGroup(formModel);
fixture.detectChanges();
}));
it('should move element up and maintain focus', () => {
const dropList = fixture.debugElement.query(By.css('.cdk-drop-list')).nativeElement;
component.handleArrowPress(new KeyboardEvent('keydown', { key: 'ArrowUp' }), dropList, 3, 1, 'up');
fixture.detectChanges();
expect(component.model.groups[0]).toBeDefined();
expect(document.activeElement).toBe(dropList.querySelectorAll('[cdkDragHandle]')[0]);
});
it('should move element down and maintain focus', () => {
const dropList = fixture.debugElement.query(By.css('.cdk-drop-list')).nativeElement;
component.handleArrowPress(new KeyboardEvent('keydown', { key: 'ArrowDown' }), dropList, 3, 1, 'down');
fixture.detectChanges();
expect(component.model.groups[2]).toBeDefined();
expect(document.activeElement).toBe(dropList.querySelectorAll('[cdkDragHandle]')[2]);
});
it('should wrap around when moving up from the first element', () => {
const dropList = fixture.debugElement.query(By.css('.cdk-drop-list')).nativeElement;
component.handleArrowPress(new KeyboardEvent('keydown', { key: 'ArrowUp' }), dropList, 3, 0, 'up');
fixture.detectChanges();
expect(component.model.groups[2]).toBeDefined();
expect(document.activeElement).toBe(dropList.querySelectorAll('[cdkDragHandle]')[2]);
});
it('should wrap around when moving down from the last element', () => {
const dropList = fixture.debugElement.query(By.css('.cdk-drop-list')).nativeElement;
component.handleArrowPress(new KeyboardEvent('keydown', { key: 'ArrowDown' }), dropList, 3, 2, 'down');
fixture.detectChanges();
expect(component.model.groups[0]).toBeDefined();
expect(document.activeElement).toBe(dropList.querySelectorAll('[cdkDragHandle]')[0]);
});
it('should not move element if keyboard drag is not active', () => {
const dropList = fixture.debugElement.query(By.css('.cdk-drop-list')).nativeElement;
component.elementBeingSorted = null;
component.handleArrowPress(new KeyboardEvent('keydown', { key: 'ArrowDown' }), dropList, 3, 1, 'down');
fixture.detectChanges();
expect(component.model.groups[1]).toBeDefined();
expect(document.activeElement).toBe(dropList.querySelectorAll('[cdkDragHandle]')[2]);
});
it('should cancel keyboard drag and drop', () => {
const dropList = fixture.debugElement.query(By.css('.cdk-drop-list')).nativeElement;
component.elementBeingSortedStartingIndex = 2;
component.elementBeingSorted = dropList.querySelectorAll('[cdkDragHandle]')[2];
component.model.moveGroup(2, 1);
fixture.detectChanges();
component.cancelKeyboardDragAndDrop(dropList, 1, 3);
fixture.detectChanges();
expect(component.elementBeingSorted).toBeNull();
expect(component.elementBeingSortedStartingIndex).toBeNull();
});
});

View File

@@ -32,9 +32,14 @@ import {
DynamicFormValidationService, DynamicFormValidationService,
DynamicTemplateDirective, DynamicTemplateDirective,
} from '@ng-dynamic-forms/core'; } from '@ng-dynamic-forms/core';
import {
TranslateModule,
TranslateService,
} from '@ngx-translate/core';
import { Relationship } from '../../../../../../core/shared/item-relationships/relationship.model'; import { Relationship } from '../../../../../../core/shared/item-relationships/relationship.model';
import { hasValue } from '../../../../../empty.util'; import { hasValue } from '../../../../../empty.util';
import { LiveRegionService } from '../../../../../live-region/live-region.service';
import { DsDynamicFormControlContainerComponent } from '../../ds-dynamic-form-control-container.component'; import { DsDynamicFormControlContainerComponent } from '../../ds-dynamic-form-control-container.component';
import { DynamicRowArrayModel } from '../ds-dynamic-row-array-model'; import { DynamicRowArrayModel } from '../ds-dynamic-row-array-model';
@@ -51,6 +56,7 @@ import { DynamicRowArrayModel } from '../ds-dynamic-row-array-model';
CdkDragHandle, CdkDragHandle,
forwardRef(() => DsDynamicFormControlContainerComponent), forwardRef(() => DsDynamicFormControlContainerComponent),
NgTemplateOutlet, NgTemplateOutlet,
TranslateModule,
], ],
standalone: true, standalone: true,
}) })
@@ -64,6 +70,9 @@ export class DsDynamicFormArrayComponent extends DynamicFormArrayComponent {
@Input() model: DynamicRowArrayModel;// DynamicRow? @Input() model: DynamicRowArrayModel;// DynamicRow?
@Input() templates: QueryList<DynamicTemplateDirective> | undefined; @Input() templates: QueryList<DynamicTemplateDirective> | undefined;
elementBeingSorted: HTMLElement;
elementBeingSortedStartingIndex: number;
/* eslint-disable @angular-eslint/no-output-rename */ /* eslint-disable @angular-eslint/no-output-rename */
@Output('dfBlur') blur: EventEmitter<DynamicFormControlEvent> = new EventEmitter<DynamicFormControlEvent>(); @Output('dfBlur') blur: EventEmitter<DynamicFormControlEvent> = new EventEmitter<DynamicFormControlEvent>();
@Output('dfChange') change: EventEmitter<DynamicFormControlEvent> = new EventEmitter<DynamicFormControlEvent>(); @Output('dfChange') change: EventEmitter<DynamicFormControlEvent> = new EventEmitter<DynamicFormControlEvent>();
@@ -74,6 +83,8 @@ export class DsDynamicFormArrayComponent extends DynamicFormArrayComponent {
constructor(protected layoutService: DynamicFormLayoutService, constructor(protected layoutService: DynamicFormLayoutService,
protected validationService: DynamicFormValidationService, protected validationService: DynamicFormValidationService,
protected liveRegionService: LiveRegionService,
protected translateService: TranslateService,
) { ) {
super(layoutService, validationService); super(layoutService, validationService);
} }
@@ -127,4 +138,149 @@ export class DsDynamicFormArrayComponent extends DynamicFormArrayComponent {
} }
return this.control.get([groupModel.startingIndex]); return this.control.get([groupModel.startingIndex]);
} }
/**
* Toggles the keyboard drag and drop feature for the given sortable element.
* @param event
* @param sortableElement
* @param index
* @param length
*/
toggleKeyboardDragAndDrop(event: KeyboardEvent, sortableElement: HTMLDivElement, index: number, length: number) {
event.preventDefault();
if (this.elementBeingSorted) {
this.stopKeyboardDragAndDrop(sortableElement, index, length);
} else {
sortableElement.classList.add('sorting-with-keyboard');
this.elementBeingSorted = sortableElement;
this.elementBeingSortedStartingIndex = index;
this.liveRegionService.clear();
this.liveRegionService.addMessage(this.translateService.instant('live-region.ordering.status', {
itemName: sortableElement.querySelector('input')?.value,
index: index + 1,
length,
}));
}
}
/**
* Stops the keyboard drag and drop feature.
* @param sortableElement
* @param index
* @param length
*/
stopKeyboardDragAndDrop(sortableElement: HTMLDivElement, index: number, length: number) {
this.elementBeingSorted?.classList.remove('sorting-with-keyboard');
this.liveRegionService.clear();
if (this.elementBeingSorted) {
this.elementBeingSorted = null;
this.elementBeingSortedStartingIndex = null;
this.liveRegionService.addMessage(this.translateService.instant('live-region.ordering.dropped', {
itemName: sortableElement.querySelector('input')?.value,
index: index + 1,
length,
}));
}
}
/**
* Handles the keyboard arrow press event to move the element up or down.
* @param event
* @param dropList
* @param length
* @param idx
* @param direction
*/
handleArrowPress(event: KeyboardEvent, dropList: HTMLDivElement, length: number, idx: number, direction: 'up' | 'down') {
let newIndex = direction === 'up' ? idx - 1 : idx + 1;
if (newIndex < 0) {
newIndex = length - 1;
} else if (newIndex >= length) {
newIndex = 0;
}
if (this.elementBeingSorted) {
this.model.moveGroup(idx, newIndex - idx);
if (hasValue(this.model.groups[newIndex]) && hasValue((this.control as any).controls[newIndex])) {
this.onCustomEvent({
previousIndex: idx,
newIndex,
arrayModel: this.model,
model: this.model.groups[newIndex].group[0],
control: (this.control as any).controls[newIndex],
}, 'move');
this.liveRegionService.clear();
this.liveRegionService.addMessage(this.translateService.instant('live-region.ordering.moved', {
itemName: this.elementBeingSorted.querySelector('input')?.value,
index: newIndex + 1,
length,
}));
}
event.preventDefault();
// Set focus back to the moved element
setTimeout(() => {
this.setFocusToDropListElementOfIndex(dropList, newIndex, direction);
});
} else {
event.preventDefault();
this.setFocusToDropListElementOfIndex(dropList, newIndex, direction);
}
}
cancelKeyboardDragAndDrop(sortableElement: HTMLDivElement, index: number, length: number) {
this.model.moveGroup(index, this.elementBeingSortedStartingIndex - index);
if (hasValue(this.model.groups[this.elementBeingSortedStartingIndex]) && hasValue((this.control as any).controls[this.elementBeingSortedStartingIndex])) {
this.onCustomEvent({
previousIndex: index,
newIndex: this.elementBeingSortedStartingIndex,
arrayModel: this.model,
model: this.model.groups[this.elementBeingSortedStartingIndex].group[0],
control: (this.control as any).controls[this.elementBeingSortedStartingIndex],
}, 'move');
this.stopKeyboardDragAndDrop(sortableElement, this.elementBeingSortedStartingIndex, length);
}
}
/**
* Sets focus to the drag handle of the drop list element of the given index.
* @param dropList
* @param index
* @param direction
*/
setFocusToDropListElementOfIndex(dropList: HTMLDivElement, index: number, direction: 'up' | 'down') {
const newDragHandle = dropList.querySelectorAll(`[cdkDragHandle]`)[index] as HTMLElement;
if (newDragHandle) {
newDragHandle.focus();
if (!this.isElementInViewport(newDragHandle)) {
newDragHandle.scrollIntoView(direction === 'up');
}
}
}
/**
* checks if an element is in the viewport
* @param el
*/
isElementInViewport(el: HTMLElement) {
const rect = el.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
/**
* Adds an instruction message to the live region when the user might want to sort an element.
* @param sortableElement
*/
addInstructionMessageToLiveRegion(sortableElement: HTMLDivElement) {
if (!this.elementBeingSorted) {
this.liveRegionService.clear();
this.liveRegionService.addMessage(this.translateService.instant('live-region.ordering.instructions', {
itemName: sortableElement.querySelector('input')?.value,
}));
}
}
} }

View File

@@ -6771,4 +6771,14 @@
"forgot-email.form.aria.label": "Enter your e-mail address", "forgot-email.form.aria.label": "Enter your e-mail address",
"search-facet-option.update.announcement": "The page will be reloaded. Filter {{ filter }} is selected.", "search-facet-option.update.announcement": "The page will be reloaded. Filter {{ filter }} is selected.",
"live-region.ordering.instructions": "Press spacebar to reorder {{ itemName }}.",
"live-region.ordering.status": "{{ itemName }}, grabbed. Current position in list: {{ index }} of {{ length }}. Press up and down arrow keys to change position, SpaceBar to drop, Escape to cancel.",
"live-region.ordering.moved": "{{ itemName }}, moved to position {{ index }} of {{ length }}. Press up and down arrow keys to change position, SpaceBar to drop, Escape to cancel.",
"live-region.ordering.dropped": "{{ itemName }}, dropped at position {{ index }} of {{ length }}.",
"dynamic-form-array.sortable-list.label": "Sortable list",
} }