diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.html index a7a6b103e47..1d395dafd96 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.html @@ -5,9 +5,11 @@ [ngClass]="getClass('element', 'control')"> -
+
-
-
+
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", }