diff --git a/config/config.example.yml b/config/config.example.yml index fbca53ca92..afd94bfbed 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -31,11 +31,11 @@ ssr: # Whether to enable rendering of Search component on SSR. # If set to true the component will be included in the HTML returned from the server side rendering. # If set to false the component will not be included in the HTML returned from the server side rendering. - enableSearchComponent: false, + enableSearchComponent: false # Whether to enable rendering of Browse component on SSR. # If set to true the component will be included in the HTML returned from the server side rendering. # If set to false the component will not be included in the HTML returned from the server side rendering. - enableBrowseComponent: false, + enableBrowseComponent: false # Enable state transfer from the server-side application to the client-side application. # Defaults to true. # Note: When using an external application cache layer, it's recommended not to transfer the state to avoid caching it. diff --git a/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.ts b/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.ts index 9bd90ea7f1..37a4d0c30f 100644 --- a/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.ts +++ b/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.ts @@ -85,7 +85,7 @@ export class ExpandableAdminSidebarSectionComponent extends AdminSidebarSectionC this.sidebarActiveBg$ = this.variableService.getVariable('--ds-admin-sidebar-active-bg'); this.isSidebarCollapsed$ = this.menuService.isMenuCollapsed(this.menuID); this.isSidebarPreviewCollapsed$ = this.menuService.isMenuPreviewCollapsed(this.menuID); - this.isExpanded$ = combineLatestObservable([this.active, this.isSidebarCollapsed$, this.isSidebarPreviewCollapsed$]).pipe( + this.isExpanded$ = combineLatestObservable([this.active$, this.isSidebarCollapsed$, this.isSidebarPreviewCollapsed$]).pipe( map(([active, sidebarCollapsed, sidebarPreviewCollapsed]) => (active && (!sidebarCollapsed || !sidebarPreviewCollapsed))), ); } diff --git a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html index 7f9b4a546f..0af5c706b7 100644 --- a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html +++ b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html @@ -1,35 +1,37 @@
- + [id]="'expandable-navbar-section-' + section.id" + (mouseenter)="onMouseEnter($event)" + (mouseleave)="onMouseLeave($event)" + data-test="navbar-section-wrapper"> + - - - - diff --git a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.spec.ts b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.spec.ts index d03c8d89eb..6f374b3aa5 100644 --- a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.spec.ts +++ b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.spec.ts @@ -1,6 +1,11 @@ -import { Component } from '@angular/core'; +import { + Component, + DebugElement, +} from '@angular/core'; import { ComponentFixture, + fakeAsync, + flush, TestBed, waitForAsync, } from '@angular/core/testing'; @@ -10,9 +15,11 @@ import { of as observableOf } from 'rxjs'; import { HostWindowService } from '../../shared/host-window.service'; import { MenuService } from '../../shared/menu/menu.service'; +import { LinkMenuItemModel } from '../../shared/menu/menu-item/models/link.model'; +import { MenuSection } from '../../shared/menu/menu-section.model'; import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub'; import { MenuServiceStub } from '../../shared/testing/menu-service.stub'; -import { VarDirective } from '../../shared/utils/var.directive'; +import { HoverOutsideDirective } from '../../shared/utils/hover-outside.directive'; import { ExpandableNavbarSectionComponent } from './expandable-navbar-section.component'; describe('ExpandableNavbarSectionComponent', () => { @@ -23,11 +30,17 @@ describe('ExpandableNavbarSectionComponent', () => { describe('on larger screens', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [NoopAnimationsModule, ExpandableNavbarSectionComponent, TestComponent, VarDirective], + imports: [ + ExpandableNavbarSectionComponent, + HoverOutsideDirective, + NoopAnimationsModule, + TestComponent, + ], providers: [ { provide: 'sectionDataProvider', useValue: {} }, { provide: MenuService, useValue: menuService }, { provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, + TestComponent, ], }).compileComponents(); })); @@ -41,10 +54,6 @@ describe('ExpandableNavbarSectionComponent', () => { fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); - }); - describe('when the mouse enters the section header (while inactive)', () => { beforeEach(() => { spyOn(component, 'onMouseEnter').and.callThrough(); @@ -141,6 +150,8 @@ describe('ExpandableNavbarSectionComponent', () => { }); describe('when spacebar is pressed on section header (while inactive)', () => { + let sidebarToggler: DebugElement; + beforeEach(() => { spyOn(component, 'toggleSection').and.callThrough(); spyOn(menuService, 'toggleActiveSection'); @@ -149,15 +160,27 @@ describe('ExpandableNavbarSectionComponent', () => { component.ngOnInit(); fixture.detectChanges(); - const sidebarToggler = fixture.debugElement.query(By.css('[data-test="navbar-section-toggler"]')); - // dispatch the (keyup.space) action used in our component HTML - sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: ' ' })); + sidebarToggler = fixture.debugElement.query(By.css('[data-test="navbar-section-toggler"]')); }); it('should call toggleSection on the menuService', () => { + // dispatch the (keyup.space) action used in our component HTML + sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { code: 'Space', key: ' ' })); + expect(component.toggleSection).toHaveBeenCalled(); expect(menuService.toggleActiveSection).toHaveBeenCalled(); }); + + // Should not do anything in order to work correctly with NVDA: https://www.nvaccess.org/ + it('should not do anything on keydown space', () => { + const event: Event = new KeyboardEvent('keydown', { code: 'Space', key: ' ' }); + spyOn(event, 'preventDefault').and.callThrough(); + + // dispatch the (keyup.space) action used in our component HTML + sidebarToggler.nativeElement.dispatchEvent(event); + + expect(event.preventDefault).toHaveBeenCalled(); + }); }); describe('when spacebar is pressed on section header (while active)', () => { @@ -179,12 +202,116 @@ describe('ExpandableNavbarSectionComponent', () => { expect(menuService.toggleActiveSection).toHaveBeenCalled(); }); }); + + describe('when enter is pressed on section header (while inactive)', () => { + let sidebarToggler: DebugElement; + + beforeEach(() => { + spyOn(component, 'toggleSection').and.callThrough(); + spyOn(menuService, 'toggleActiveSection'); + // Make sure section is 'inactive'. Requires calling ngOnInit() to update component 'active' property. + spyOn(menuService, 'isSectionActive').and.returnValue(observableOf(false)); + component.ngOnInit(); + fixture.detectChanges(); + + sidebarToggler = fixture.debugElement.query(By.css('[data-test="navbar-section-toggler"]')); + }); + + // Should not do anything in order to work correctly with NVDA: https://www.nvaccess.org/ + it('should not do anything on keydown space', () => { + const event: Event = new KeyboardEvent('keydown', { code: 'Enter' }); + spyOn(event, 'preventDefault').and.callThrough(); + + // dispatch the (keyup.space) action used in our component HTML + sidebarToggler.nativeElement.dispatchEvent(event); + + expect(event.preventDefault).toHaveBeenCalled(); + }); + }); + + describe('when arrow down is pressed on section header', () => { + it('should call activateSection', () => { + spyOn(component, 'activateSection').and.callThrough(); + + const sidebarToggler: DebugElement = fixture.debugElement.query(By.css('[data-test="navbar-section-toggler"]')); + // dispatch the (keydown.ArrowDown) action used in our component HTML + sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { code: 'ArrowDown' })); + + expect(component.focusOnFirstChildSection).toBe(true); + expect(component.activateSection).toHaveBeenCalled(); + }); + }); + + describe('when tab is pressed on section header', () => { + it('should call deactivateSection', () => { + spyOn(component, 'deactivateSection').and.callThrough(); + + const sidebarToggler: DebugElement = fixture.debugElement.query(By.css('[data-test="navbar-section-toggler"]')); + // dispatch the (keydown.ArrowDown) action used in our component HTML + sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { code: 'Tab' })); + + expect(component.deactivateSection).toHaveBeenCalled(); + }); + }); + + describe('navigateDropdown', () => { + beforeEach(fakeAsync(() => { + jasmine.getEnv().allowRespy(true); + spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([ + Object.assign(new MenuSection(), { + id: 'subSection1', + model: Object.assign(new LinkMenuItemModel(), { + type: 'TEST_LINK', + }), + parentId: component.section.id, + }), + Object.assign(new MenuSection(), { + id: 'subSection2', + model: Object.assign(new LinkMenuItemModel(), { + type: 'TEST_LINK', + }), + parentId: component.section.id, + }), + ])); + component.ngOnInit(); + flush(); + fixture.detectChanges(); + component.focusOnFirstChildSection = true; + component.active$.next(true); + fixture.detectChanges(); + })); + + it('should close the modal on Tab', () => { + spyOn(menuService, 'deactivateSection').and.callThrough(); + + const firstSubsection: DebugElement = fixture.debugElement.queryAll(By.css('.dropdown-menu a[role="menuitem"]'))[0]; + firstSubsection.nativeElement.focus(); + firstSubsection.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { code: 'Tab' })); + + expect(menuService.deactivateSection).toHaveBeenCalled(); + }); + + it('should close the modal on Escape', () => { + spyOn(menuService, 'deactivateSection').and.callThrough(); + + const firstSubsection: DebugElement = fixture.debugElement.queryAll(By.css('.dropdown-menu a[role="menuitem"]'))[0]; + firstSubsection.nativeElement.focus(); + firstSubsection.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { code: 'Escape' })); + + expect(menuService.deactivateSection).toHaveBeenCalled(); + }); + }); }); describe('on smaller, mobile screens', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [NoopAnimationsModule, ExpandableNavbarSectionComponent, TestComponent, VarDirective], + imports: [ + ExpandableNavbarSectionComponent, + HoverOutsideDirective, + NoopAnimationsModule, + TestComponent, + ], providers: [ { provide: 'sectionDataProvider', useValue: {} }, { provide: MenuService, useValue: menuService }, @@ -253,7 +380,9 @@ describe('ExpandableNavbarSectionComponent', () => { // declare a test component @Component({ selector: 'ds-test-cmp', - template: ``, + template: ` + link + `, standalone: true, }) class TestComponent { diff --git a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts index 92da978af7..dc3db79aca 100644 --- a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts +++ b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts @@ -5,10 +5,12 @@ import { NgIf, } from '@angular/common'; import { + AfterViewChecked, Component, HostListener, Inject, Injector, + OnDestroy, OnInit, } from '@angular/core'; import { RouterLinkActive } from '@angular/router'; @@ -19,7 +21,8 @@ import { slide } from '../../shared/animations/slide'; import { HostWindowService } from '../../shared/host-window.service'; import { MenuService } from '../../shared/menu/menu.service'; import { MenuID } from '../../shared/menu/menu-id.model'; -import { VarDirective } from '../../shared/utils/var.directive'; +import { MenuSection } from '../../shared/menu/menu-section.model'; +import { HoverOutsideDirective } from '../../shared/utils/hover-outside.directive'; import { NavbarSectionComponent } from '../navbar-section/navbar-section.component'; /** @@ -31,9 +34,17 @@ import { NavbarSectionComponent } from '../navbar-section/navbar-section.compone styleUrls: ['./expandable-navbar-section.component.scss'], animations: [slide], standalone: true, - imports: [VarDirective, RouterLinkActive, NgComponentOutlet, NgIf, NgFor, AsyncPipe], + imports: [ + AsyncPipe, + HoverOutsideDirective, + NgComponentOutlet, + NgFor, + NgIf, + RouterLinkActive, + ], }) -export class ExpandableNavbarSectionComponent extends NavbarSectionComponent implements OnInit { +export class ExpandableNavbarSectionComponent extends NavbarSectionComponent implements AfterViewChecked, OnInit, OnDestroy { + /** * This section resides in the Public Navbar */ @@ -44,6 +55,11 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp */ mouseEntered = false; + /** + * Whether the section was expanded + */ + focusOnFirstChildSection = false; + /** * True if screen size was small before a resize event */ @@ -54,6 +70,18 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp */ isMobile$: Observable; + /** + * Boolean used to add the event listeners to the items in the expandable menu when expanded. This is done for + * performance reasons, there is currently an *ngIf on the menu to prevent the {@link HoverOutsideDirective} to tank + * performance when not expanded. + */ + addArrowEventListeners = false; + + /** + * List of current dropdown items who have event listeners + */ + private dropdownItems: NodeListOf; + @HostListener('window:resize', ['$event']) onResize() { this.isMobile$.pipe( @@ -68,29 +96,80 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp }); } - constructor(@Inject('sectionDataProvider') menuSection, - protected menuService: MenuService, - protected injector: Injector, - private windowService: HostWindowService, + constructor( + @Inject('sectionDataProvider') public section: MenuSection, + protected menuService: MenuService, + protected injector: Injector, + protected windowService: HostWindowService, ) { - super(menuSection, menuService, injector); + super(section, menuService, injector); this.isMobile$ = this.windowService.isMobile(); } ngOnInit() { super.ngOnInit(); + this.subs.push(this.active$.subscribe((active: boolean) => { + if (active === true) { + this.addArrowEventListeners = true; + } else { + this.focusOnFirstChildSection = undefined; + this.unsubscribeFromEventListeners(); + } + })); + } + + ngAfterViewChecked(): void { + if (this.addArrowEventListeners) { + this.dropdownItems = document.querySelectorAll(`#${this.expandableNavbarSectionId()} *[role="menuitem"]`); + this.dropdownItems.forEach((item: HTMLElement) => { + item.addEventListener('keydown', this.navigateDropdown.bind(this)); + }); + if (this.focusOnFirstChildSection && this.dropdownItems.length > 0) { + this.dropdownItems.item(0).focus(); + } + this.addArrowEventListeners = false; + } + } + + ngOnDestroy(): void { + super.ngOnDestroy(); + this.unsubscribeFromEventListeners(); + } + + /** + * Activate this section if it's currently inactive, deactivate it when it's currently active. + * Also saves whether this toggle was performed by a keyboard event (non-click event) in order to know if thi first + * item should be focussed when activating a section. + * + * @param {Event} event The user event that triggered this method + */ + override toggleSection(event: Event): void { + this.focusOnFirstChildSection = event.type !== 'click'; + super.toggleSection(event); + } + + /** + * Removes all the current event listeners on the dropdown items (called when the menu is closed & on component + * destruction) + */ + unsubscribeFromEventListeners(): void { + if (this.dropdownItems) { + this.dropdownItems.forEach((item: HTMLElement) => { + item.removeEventListener('keydown', this.navigateDropdown.bind(this)); + }); + this.dropdownItems = undefined; + } } /** * When the mouse enters the section toggler activate the menu section * @param $event - * @param isActive */ - onMouseEnter($event: Event, isActive: boolean) { + onMouseEnter($event: Event): void { this.isMobile$.pipe( first(), ).subscribe((isMobile) => { - if (!isMobile && !isActive && !this.mouseEntered) { + if (!isMobile && !this.active$.value && !this.mouseEntered) { this.activateSection($event); } this.mouseEntered = true; @@ -100,13 +179,12 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp /** * When the mouse leaves the section toggler deactivate the menu section * @param $event - * @param isActive */ - onMouseLeave($event: Event, isActive: boolean) { + onMouseLeave($event: Event): void { this.isMobile$.pipe( first(), ).subscribe((isMobile) => { - if (!isMobile && isActive && this.mouseEntered) { + if (!isMobile && this.active$.value && this.mouseEntered) { this.deactivateSection($event); } this.mouseEntered = false; @@ -115,9 +193,60 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp /** * returns the ID of the DOM element representing the navbar section - * @param sectionId */ - expandableNavbarSectionId(sectionId: string) { - return `expandable-navbar-section-${sectionId}-dropdown`; + expandableNavbarSectionId(): string { + return `expandable-navbar-section-${this.section.id}-dropdown`; + } + + /** + * Handles the navigation between the menu items + * + * @param event + */ + navigateDropdown(event: KeyboardEvent): void { + if (event.code === 'Tab') { + this.deactivateSection(event, false); + return; + } else if (event.code === 'Escape') { + this.deactivateSection(event, false); + (document.querySelector(`a[aria-controls="${this.expandableNavbarSectionId()}"]`) as HTMLElement)?.focus(); + return; + } + event.preventDefault(); + event.stopPropagation(); + + const items: NodeListOf = document.querySelectorAll(`#${this.expandableNavbarSectionId()} *[role="menuitem"]`); + if (items.length === 0) { + return; + } + const currentIndex: number = Array.from(items).findIndex((item: Element) => item === event.target); + + if (event.key === 'ArrowDown') { + (items[(currentIndex + 1) % items.length] as HTMLElement).focus(); + } else if (event.key === 'ArrowUp') { + (items[(currentIndex - 1 + items.length) % items.length] as HTMLElement).focus(); + } + } + + /** + * Handles all the keydown events on the dropdown toggle + * + * @param event + */ + keyDown(event: KeyboardEvent): void { + switch (event.code) { + // Works for both Tab & Shift Tab + case 'Tab': + this.deactivateSection(event, false); + break; + case 'ArrowDown': + this.focusOnFirstChildSection = true; + this.activateSection(event); + break; + case 'Space': + case 'Enter': + event.preventDefault(); + break; + } } } 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 a7a6b103e4..3ad7063256 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,10 @@ [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 ce4a89226a..f93fcb1eac 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 0000000000..0d9ed6ae74 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.spec.ts @@ -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; + + 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(); + }); +}); 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 0ae397ce61..220143291e 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 @@ -32,9 +32,14 @@ import { DynamicFormValidationService, DynamicTemplateDirective, } from '@ng-dynamic-forms/core'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; import { Relationship } from '../../../../../../core/shared/item-relationships/relationship.model'; import { hasValue } from '../../../../../empty.util'; +import { LiveRegionService } from '../../../../../live-region/live-region.service'; import { DsDynamicFormControlContainerComponent } from '../../ds-dynamic-form-control-container.component'; import { DynamicRowArrayModel } from '../ds-dynamic-row-array-model'; @@ -51,6 +56,7 @@ import { DynamicRowArrayModel } from '../ds-dynamic-row-array-model'; CdkDragHandle, forwardRef(() => DsDynamicFormControlContainerComponent), NgTemplateOutlet, + TranslateModule, ], standalone: true, }) @@ -64,6 +70,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(); @@ -74,6 +83,8 @@ export class DsDynamicFormArrayComponent extends DynamicFormArrayComponent { constructor(protected layoutService: DynamicFormLayoutService, protected validationService: DynamicFormValidationService, + protected liveRegionService: LiveRegionService, + protected translateService: TranslateService, ) { super(layoutService, validationService); } @@ -127,4 +138,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/app/shared/menu/menu-section/menu-section.component.ts b/src/app/shared/menu/menu-section/menu-section.component.ts index f59c0b5594..d14cfb143c 100644 --- a/src/app/shared/menu/menu-section/menu-section.component.ts +++ b/src/app/shared/menu/menu-section/menu-section.component.ts @@ -37,9 +37,9 @@ import { MenuSection } from '../menu-section.model'; export class MenuSectionComponent implements OnInit, OnDestroy { /** - * Observable that emits whether or not this section is currently active + * {@link BehaviorSubject} containing the current state to whether this section is currently active */ - active: Observable; + active$: BehaviorSubject = new BehaviorSubject(false); /** * The ID of the menu this section resides in @@ -72,7 +72,11 @@ export class MenuSectionComponent implements OnInit, OnDestroy { * Set initial values for instance variables */ ngOnInit(): void { - this.active = this.menuService.isSectionActive(this.menuID, this.section.id).pipe(distinctUntilChanged()); + this.subs.push(this.menuService.isSectionActive(this.menuID, this.section.id).pipe(distinctUntilChanged()).subscribe((isActive: boolean) => { + if (this.active$.value !== isActive) { + this.active$.next(isActive); + } + })); this.initializeInjectorData(); } @@ -90,9 +94,12 @@ export class MenuSectionComponent implements OnInit, OnDestroy { /** * Activate this section * @param {Event} event The user event that triggered this method + * @param skipEvent Weather the event should still be triggered after deactivating the section or not */ - activateSection(event: Event) { - event.preventDefault(); + activateSection(event: Event, skipEvent = true): void { + if (skipEvent) { + event.preventDefault(); + } if (!this.section.model?.disabled) { this.menuService.activateSection(this.menuID, this.section.id); } @@ -100,10 +107,14 @@ export class MenuSectionComponent implements OnInit, OnDestroy { /** * Deactivate this section + * * @param {Event} event The user event that triggered this method + * @param skipEvent Weather the event should still be triggered after deactivating the section or not */ - deactivateSection(event: Event) { - event.preventDefault(); + deactivateSection(event: Event, skipEvent = true): void { + if (skipEvent) { + event.preventDefault(); + } this.menuService.deactivateSection(this.menuID, this.section.id); } diff --git a/src/app/shared/utils/hover-outside.directive.ts b/src/app/shared/utils/hover-outside.directive.ts new file mode 100644 index 0000000000..e046d3a087 --- /dev/null +++ b/src/app/shared/utils/hover-outside.directive.ts @@ -0,0 +1,53 @@ +import { + Directive, + ElementRef, + EventEmitter, + HostListener, + Input, + Output, +} from '@angular/core'; + +/** + * Directive to detect when the user hovers outside the element the directive was put on + * + * **Performance Consideration**: it's probably not good for performance to use this excessively (on + * {@link ExpandableNavbarSectionComponent} for example, a workaround for this problem was to add an `*ngIf` to prevent + * this Directive from always being active) + */ +@Directive({ + selector: '[dsHoverOutside]', + standalone: true, +}) +export class HoverOutsideDirective { + + /** + * Emits null when the user hovers outside of the element + */ + @Output() + public dsHoverOutside = new EventEmitter(); + + /** + * CSS selector for the parent element to monitor. If set, the directive will use this + * selector to determine if the hover event originated within the selected parent element. + * If left unset, the directive will monitor mouseover hover events for the element it is applied to. + */ + @Input() + public dsHoverOutsideOfParentSelector: string; + + constructor( + private elementRef: ElementRef, + ) { + } + + @HostListener('document:mouseover', ['$event']) + public onMouseOver(event: MouseEvent): void { + const targetElement: HTMLElement = event.target as HTMLElement; + const element: Element = document.querySelector(this.dsHoverOutsideOfParentSelector); + const hoveredInside = (element ? new ElementRef(element) : this.elementRef).nativeElement.contains(targetElement); + + if (!hoveredInside) { + this.dsHoverOutside.emit(null); + } + } + +} diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 4671fd8397..053e5133d9 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -6771,4 +6771,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", } diff --git a/src/themes/custom/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts b/src/themes/custom/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts index f0e2ebd502..b0b57a75aa 100644 --- a/src/themes/custom/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts +++ b/src/themes/custom/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts @@ -9,11 +9,8 @@ import { RouterLinkActive } from '@angular/router'; import { ExpandableNavbarSectionComponent as BaseComponent } from '../../../../../app/navbar/expandable-navbar-section/expandable-navbar-section.component'; import { slide } from '../../../../../app/shared/animations/slide'; -import { VarDirective } from '../../../../../app/shared/utils/var.directive'; +import { HoverOutsideDirective } from '../../../../../app/shared/utils/hover-outside.directive'; -/** - * Represents an expandable section in the navbar - */ @Component({ selector: 'ds-themed-expandable-navbar-section', // templateUrl: './expandable-navbar-section.component.html', @@ -22,7 +19,14 @@ import { VarDirective } from '../../../../../app/shared/utils/var.directive'; styleUrls: ['../../../../../app/navbar/expandable-navbar-section/expandable-navbar-section.component.scss'], animations: [slide], standalone: true, - imports: [VarDirective, RouterLinkActive, NgComponentOutlet, NgIf, NgFor, AsyncPipe], + imports: [ + AsyncPipe, + HoverOutsideDirective, + NgComponentOutlet, + NgFor, + NgIf, + RouterLinkActive, + ], }) export class ExpandableNavbarSectionComponent extends BaseComponent { }