Skip to content

Commit 8313b9a

Browse files
author
Andrea Barbasso
committed
[CST-15595] add keyboard drag and drop functionality
1 parent 0abbf80 commit 8313b9a

File tree

5 files changed

+349
-13
lines changed

5 files changed

+349
-13
lines changed

src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.html

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
[ngClass]="getClass('element', 'control')">
66

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

src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.scss

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
margin-right: calc(-0.5 * var(--bs-spacer));
1818
padding-right: calc(0.5 * var(--bs-spacer));
1919
.drag-icon {
20-
visibility: hidden;
2120
width: calc(2 * var(--bs-spacer));
2221
color: var(--bs-gray-600);
2322
margin: var(--bs-btn-padding-y) 0;
@@ -27,9 +26,6 @@
2726

2827
&:hover, &:focus {
2928
cursor: grab;
30-
.drag-icon {
31-
visibility: visible;
32-
}
3329
}
3430

3531
}
@@ -40,18 +36,12 @@
4036
}
4137

4238
&:focus {
43-
.drag-icon {
44-
visibility: visible;
45-
}
4639
}
4740
}
4841

4942
.cdk-drop-list-dragging {
5043
.drag-handle {
5144
cursor: grabbing;
52-
.drag-icon {
53-
visibility: hidden;
54-
}
5545
}
5646
}
5747

@@ -63,3 +53,9 @@
6353
.cdk-drag-placeholder {
6454
opacity: 0;
6555
}
56+
57+
::ng-deep {
58+
.sorting-with-keyboard input {
59+
background-color: var(--bs-gray-400);
60+
}
61+
}
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { HttpClient } from '@angular/common/http';
2+
import { EventEmitter } from '@angular/core';
3+
import {
4+
ComponentFixture,
5+
inject,
6+
TestBed,
7+
} from '@angular/core/testing';
8+
import { ReactiveFormsModule } from '@angular/forms';
9+
import { By } from '@angular/platform-browser';
10+
import {
11+
DynamicFormLayoutService,
12+
DynamicFormService,
13+
DynamicFormValidationService,
14+
DynamicInputModel,
15+
} from '@ng-dynamic-forms/core';
16+
import { provideMockStore } from '@ngrx/store/testing';
17+
import {
18+
TranslateLoader,
19+
TranslateModule,
20+
TranslateService,
21+
} from '@ngx-translate/core';
22+
import { NgxMaskModule } from 'ngx-mask';
23+
import { of } from 'rxjs';
24+
25+
import {
26+
APP_CONFIG,
27+
} from '../../../../../../../config/app-config.interface';
28+
import { environment } from '../../../../../../../environments/environment.test';
29+
import { SubmissionService } from '../../../../../../submission/submission.service';
30+
import { DsDynamicFormControlContainerComponent } from '../../ds-dynamic-form-control-container.component';
31+
import { DynamicRowArrayModel } from '../ds-dynamic-row-array-model';
32+
import { DsDynamicFormArrayComponent } from './dynamic-form-array.component';
33+
import { UUIDService } from '../../../../../../core/shared/uuid.service';
34+
import { TranslateLoaderMock } from '../../../../../mocks/translate-loader.mock';
35+
36+
describe('DsDynamicFormArrayComponent', () => {
37+
const translateServiceStub = {
38+
get: () => of('translated-text'),
39+
instant: () => 'translated-text',
40+
onLangChange: new EventEmitter(),
41+
onTranslationChange: new EventEmitter(),
42+
onDefaultLangChange: new EventEmitter(),
43+
};
44+
45+
const uuidServiceStub = {
46+
generate: () => 'fake-id'
47+
};
48+
49+
let component: DsDynamicFormArrayComponent;
50+
let fixture: ComponentFixture<DsDynamicFormArrayComponent>;
51+
52+
beforeEach(async () => {
53+
await TestBed.configureTestingModule({
54+
declarations: [
55+
DsDynamicFormArrayComponent,
56+
],
57+
imports: [
58+
ReactiveFormsModule,
59+
NgxMaskModule.forRoot(),
60+
TranslateModule.forRoot({
61+
loader: {
62+
provide: TranslateLoader,
63+
useClass: TranslateLoaderMock
64+
}
65+
}),
66+
],
67+
providers: [
68+
DynamicFormLayoutService,
69+
DynamicFormValidationService,
70+
provideMockStore(),
71+
{ provide: TranslateService, useValue: translateServiceStub },
72+
{ provide: HttpClient, useValue: {} },
73+
{ provide: SubmissionService, useValue: {} },
74+
{ provide: APP_CONFIG, useValue: environment },
75+
{ provide: UUIDService, useValue: uuidServiceStub },
76+
],
77+
}).overrideComponent(DsDynamicFormArrayComponent, {
78+
remove: {
79+
imports: [DsDynamicFormControlContainerComponent],
80+
},
81+
})
82+
.compileComponents();
83+
});
84+
85+
beforeEach(inject([DynamicFormService], (service: DynamicFormService) => {
86+
const formModel = [
87+
new DynamicRowArrayModel({
88+
id: 'testFormRowArray',
89+
initialCount: 5,
90+
notRepeatable: false,
91+
relationshipConfig: undefined,
92+
submissionId: '1234',
93+
isDraggable: true,
94+
groupFactory: () => {
95+
return [
96+
new DynamicInputModel({ id: 'testFormRowArrayGroupInput' }),
97+
];
98+
},
99+
required: false,
100+
metadataKey: 'dc.contributor.author',
101+
metadataFields: ['dc.contributor.author'],
102+
hasSelectableMetadata: true,
103+
showButtons: true,
104+
typeBindRelations: [{ match: 'VISIBLE', operator: 'OR', when: [{ id: 'dc.type', value: 'Book' }] }],
105+
}),
106+
];
107+
108+
fixture = TestBed.createComponent(DsDynamicFormArrayComponent);
109+
component = fixture.componentInstance;
110+
component.model = formModel[0] as DynamicRowArrayModel;
111+
112+
component.group = service.createFormGroup(formModel);
113+
114+
fixture.detectChanges();
115+
}));
116+
117+
it('should move element up and maintain focus', () => {
118+
const dropList = fixture.debugElement.query(By.css('[cdkDropList]')).nativeElement;
119+
component.handleArrowPress(new KeyboardEvent('keydown', { key: 'ArrowUp' }), dropList, 3, 1, 'up');
120+
fixture.detectChanges();
121+
expect(component.model.groups[0]).toBeDefined();
122+
expect(document.activeElement).toBe(dropList.querySelectorAll('[cdkDragHandle]')[0]);
123+
});
124+
125+
it('should move element down and maintain focus', () => {
126+
const dropList = fixture.debugElement.query(By.css('[cdkDropList]')).nativeElement;
127+
component.handleArrowPress(new KeyboardEvent('keydown', { key: 'ArrowDown' }), dropList, 3, 1, 'down');
128+
fixture.detectChanges();
129+
expect(component.model.groups[2]).toBeDefined();
130+
expect(document.activeElement).toBe(dropList.querySelectorAll('[cdkDragHandle]')[2]);
131+
});
132+
133+
it('should wrap around when moving up from the first element', () => {
134+
const dropList = fixture.debugElement.query(By.css('[cdkDropList]')).nativeElement;
135+
component.handleArrowPress(new KeyboardEvent('keydown', { key: 'ArrowUp' }), dropList, 3, 0, 'up');
136+
fixture.detectChanges();
137+
expect(component.model.groups[2]).toBeDefined();
138+
expect(document.activeElement).toBe(dropList.querySelectorAll('[cdkDragHandle]')[2]);
139+
});
140+
141+
it('should wrap around when moving down from the last element', () => {
142+
const dropList = fixture.debugElement.query(By.css('[cdkDropList]')).nativeElement;
143+
component.handleArrowPress(new KeyboardEvent('keydown', { key: 'ArrowDown' }), dropList, 3, 2, 'down');
144+
fixture.detectChanges();
145+
expect(component.model.groups[0]).toBeDefined();
146+
expect(document.activeElement).toBe(dropList.querySelectorAll('[cdkDragHandle]')[0]);
147+
});
148+
149+
it('should not move element if keyboard drag is not active', () => {
150+
const dropList = fixture.debugElement.query(By.css('[cdkDropList]')).nativeElement;
151+
component.elementBeingSorted = null;
152+
component.handleArrowPress(new KeyboardEvent('keydown', { key: 'ArrowDown' }), dropList, 3, 1, 'down');
153+
fixture.detectChanges();
154+
expect(component.model.groups[1]).toBeDefined();
155+
expect(document.activeElement).toBe(dropList.querySelectorAll('[cdkDragHandle]')[2]);
156+
});
157+
158+
it('should cancel keyboard drag and drop', () => {
159+
const dropList = fixture.debugElement.query(By.css('[cdkDropList]')).nativeElement;
160+
component.elementBeingSortedStartingIndex = 2;
161+
component.elementBeingSorted = dropList.querySelectorAll('[cdkDragHandle]')[2];
162+
component.model.moveGroup(2, 1);
163+
fixture.detectChanges();
164+
component.cancelKeyboardDragAndDrop(dropList, 1, 3);
165+
fixture.detectChanges();
166+
expect(component.elementBeingSorted).toBeNull();
167+
expect(component.elementBeingSortedStartingIndex).toBeNull();
168+
});
169+
});

0 commit comments

Comments
 (0)