Skip to content

Commit

Permalink
Make reorder buttons keyboard accessible (#3372)
Browse files Browse the repository at this point in the history
* [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´>
  • Loading branch information
AndreaBarbasso and Andrea Barbasso authored Jan 30, 2025
1 parent e867993 commit bdac58d
Show file tree
Hide file tree
Showing 5 changed files with 342 additions and 13 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
[ngClass]="getClass('element', 'control')">

<!-- 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 -->
<div *ngFor="let groupModel of model.groups"
<div #sortableElement
*ngFor="let groupModel of model.groups; let idx = index; let length = count"
role="group"
[formGroupName]="groupModel.index"
[ngClass]="[getClass('element', 'group'), getClass('grid', 'group')]"
Expand All @@ -16,7 +17,14 @@
[cdkDragPreviewClass]="'ds-submission-reorder-dragging'"
[class.grey-background]="model.isInlineGroupArray">
<!-- 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>
</div>
<ng-container *ngTemplateOutlet="startTemplate?.templateRef; context: groupModel"></ng-container>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
margin-right: calc(-0.5 * var(--bs-spacer));
padding-right: calc(0.5 * var(--bs-spacer));
.drag-icon {
visibility: hidden;
width: calc(2 * var(--bs-spacer));
color: var(--bs-gray-600);
margin: var(--bs-btn-padding-y) 0;
Expand All @@ -27,9 +26,6 @@

&:hover, &:focus {
cursor: grab;
.drag-icon {
visibility: visible;
}
}

}
Expand All @@ -40,18 +36,12 @@
}

&:focus {
.drag-icon {
visibility: visible;
}
}
}

.cdk-drop-list-dragging {
.drag-handle {
cursor: grabbing;
.drag-icon {
visibility: hidden;
}
}
}

Expand All @@ -63,3 +53,9 @@
.cdk-drag-placeholder {
opacity: 0;
}

::ng-deep {
.sorting-with-keyboard input {
background-color: var(--bs-gray-400);
}
}
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading

0 comments on commit bdac58d

Please sign in to comment.