+
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.scss
index ce4a89226ad..f93fcb1eac3 100644
--- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.scss
+++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.scss
@@ -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;
@@ -27,9 +26,6 @@
&:hover, &:focus {
cursor: grab;
- .drag-icon {
- visibility: visible;
- }
}
}
@@ -40,18 +36,12 @@
}
&:focus {
- .drag-icon {
- visibility: visible;
- }
}
}
.cdk-drop-list-dragging {
.drag-handle {
cursor: grabbing;
- .drag-icon {
- visibility: hidden;
- }
}
}
@@ -63,3 +53,9 @@
.cdk-drag-placeholder {
opacity: 0;
}
+
+::ng-deep {
+ .sorting-with-keyboard input {
+ background-color: var(--bs-gray-400);
+ }
+}
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.spec.ts
new file mode 100644
index 00000000000..dc6565a0a4a
--- /dev/null
+++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.spec.ts
@@ -0,0 +1,169 @@
+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 {
+ DynamicFormLayoutService,
+ DynamicFormService,
+ DynamicFormValidationService,
+ DynamicInputModel,
+} from '@ng-dynamic-forms/core';
+import { provideMockStore } from '@ngrx/store/testing';
+import {
+ TranslateLoader,
+ TranslateModule,
+ TranslateService,
+} from '@ngx-translate/core';
+import { NgxMaskModule } from 'ngx-mask';
+import { of } from 'rxjs';
+
+import {
+ APP_CONFIG,
+} 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 { DynamicRowArrayModel } from '../ds-dynamic-row-array-model';
+import { DsDynamicFormArrayComponent } from './dynamic-form-array.component';
+import { UUIDService } from '../../../../../../core/shared/uuid.service';
+import { TranslateLoaderMock } from '../../../../../mocks/translate-loader.mock';
+
+describe('DsDynamicFormArrayComponent', () => {
+ const translateServiceStub = {
+ get: () => of('translated-text'),
+ instant: () => 'translated-text',
+ onLangChange: new EventEmitter(),
+ onTranslationChange: new EventEmitter(),
+ onDefaultLangChange: new EventEmitter(),
+ };
+
+ const uuidServiceStub = {
+ generate: () => 'fake-id'
+ };
+
+ let component: DsDynamicFormArrayComponent;
+ let fixture: ComponentFixture
;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [
+ DsDynamicFormArrayComponent,
+ ],
+ imports: [
+ ReactiveFormsModule,
+ NgxMaskModule.forRoot(),
+ TranslateModule.forRoot({
+ loader: {
+ provide: TranslateLoader,
+ useClass: TranslateLoaderMock
+ }
+ }),
+ ],
+ providers: [
+ DynamicFormLayoutService,
+ DynamicFormValidationService,
+ provideMockStore(),
+ { provide: TranslateService, useValue: translateServiceStub },
+ { provide: HttpClient, useValue: {} },
+ { provide: SubmissionService, useValue: {} },
+ { provide: APP_CONFIG, useValue: environment },
+ { provide: UUIDService, useValue: uuidServiceStub },
+ ],
+ }).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('[cdkDropList]')).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('[cdkDropList]')).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('[cdkDropList]')).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('[cdkDropList]')).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('[cdkDropList]')).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('[cdkDropList]')).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();
+ });
+});
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.ts
index 1cbee3eb905..fb7ee7ac2e8 100644
--- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.ts
+++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.ts
@@ -15,6 +15,8 @@ import {
import { Relationship } from '../../../../../../core/shared/item-relationships/relationship.model';
import { hasValue } from '../../../../../empty.util';
import { DynamicRowArrayModel } from '../ds-dynamic-row-array-model';
+import { LiveRegionService } from '../../../../../live-region/live-region.service';
+import { TranslateService } from '@ngx-translate/core';
@Component({
selector: 'ds-dynamic-form-array',
@@ -31,6 +33,9 @@ export class DsDynamicFormArrayComponent extends DynamicFormArrayComponent {
@Input() model: DynamicRowArrayModel;// DynamicRow?
@Input() templates: QueryList | undefined;
+ elementBeingSorted: HTMLElement;
+ elementBeingSortedStartingIndex: number;
+
/* eslint-disable @angular-eslint/no-output-rename */
@Output('dfBlur') blur: EventEmitter = new EventEmitter();
@Output('dfChange') change: EventEmitter = new EventEmitter();
@@ -41,6 +46,8 @@ export class DsDynamicFormArrayComponent extends DynamicFormArrayComponent {
constructor(protected layoutService: DynamicFormLayoutService,
protected validationService: DynamicFormValidationService,
+ protected liveRegionService: LiveRegionService,
+ protected translateService: TranslateService,
) {
super(layoutService, validationService);
}
@@ -94,4 +101,149 @@ export class DsDynamicFormArrayComponent extends DynamicFormArrayComponent {
}
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,
+ }));
+ }
+ }
}
diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5
index 9838cd642e1..99ce1962544 100644
--- a/src/assets/i18n/en.json5
+++ b/src/assets/i18n/en.json5
@@ -5403,4 +5403,14 @@
"forgot-email.form.aria.label": "Enter your e-mail address",
"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",
}