mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 10:04:11 +00:00
Merge branch 'main' into task/main/DURACOM-288
# Conflicts: # config/config.example.yml
This commit is contained in:
@@ -31,11 +31,11 @@ ssr:
|
|||||||
# Whether to enable rendering of Search component on 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 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.
|
# 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.
|
# 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 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.
|
# 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.
|
# Enable state transfer from the server-side application to the client-side application.
|
||||||
# Defaults to true.
|
# Defaults to true.
|
||||||
# Note: When using an external application cache layer, it's recommended not to transfer the state to avoid caching it.
|
# Note: When using an external application cache layer, it's recommended not to transfer the state to avoid caching it.
|
||||||
|
@@ -85,7 +85,7 @@ export class ExpandableAdminSidebarSectionComponent extends AdminSidebarSectionC
|
|||||||
this.sidebarActiveBg$ = this.variableService.getVariable('--ds-admin-sidebar-active-bg');
|
this.sidebarActiveBg$ = this.variableService.getVariable('--ds-admin-sidebar-active-bg');
|
||||||
this.isSidebarCollapsed$ = this.menuService.isMenuCollapsed(this.menuID);
|
this.isSidebarCollapsed$ = this.menuService.isMenuCollapsed(this.menuID);
|
||||||
this.isSidebarPreviewCollapsed$ = this.menuService.isMenuPreviewCollapsed(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))),
|
map(([active, sidebarCollapsed, sidebarPreviewCollapsed]) => (active && (!sidebarCollapsed || !sidebarPreviewCollapsed))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -1,35 +1,37 @@
|
|||||||
<div class="ds-menu-item-wrapper text-md-center"
|
<div class="ds-menu-item-wrapper text-md-center"
|
||||||
[id]="'expandable-navbar-section-' + section.id"
|
[id]="'expandable-navbar-section-' + section.id"
|
||||||
(mouseenter)="onMouseEnter($event, isActive)"
|
(mouseenter)="onMouseEnter($event)"
|
||||||
(mouseleave)="onMouseLeave($event, isActive)"
|
(mouseleave)="onMouseLeave($event)"
|
||||||
data-test="navbar-section-wrapper"
|
data-test="navbar-section-wrapper">
|
||||||
*ngVar="(active | async) as isActive">
|
<a href="javascript:void(0);" routerLinkActive="active"
|
||||||
<a href="javascript:void(0);" routerLinkActive="active"
|
role="menuitem"
|
||||||
role="menuitem"
|
(keyup.enter)="toggleSection($event)"
|
||||||
(keyup.enter)="toggleSection($event)"
|
(keyup.space)="toggleSection($event)"
|
||||||
(keyup.space)="toggleSection($event)"
|
(click)="toggleSection($event)"
|
||||||
(click)="toggleSection($event)"
|
(keydown)="keyDown($event)"
|
||||||
(keydown.space)="$event.preventDefault()"
|
aria-haspopup="menu"
|
||||||
aria-haspopup="menu"
|
data-test="navbar-section-toggler"
|
||||||
data-test="navbar-section-toggler"
|
[attr.aria-expanded]="(active$ | async).valueOf()"
|
||||||
[attr.aria-expanded]="isActive"
|
[attr.aria-controls]="expandableNavbarSectionId()"
|
||||||
[attr.aria-controls]="expandableNavbarSectionId(section.id)"
|
class="d-flex flex-row flex-nowrap align-items-center gapx-1 ds-menu-toggler-wrapper"
|
||||||
class="d-flex flex-row flex-nowrap align-items-center gapx-1 ds-menu-toggler-wrapper"
|
[class.disabled]="section.model?.disabled">
|
||||||
[class.disabled]="section.model?.disabled">
|
|
||||||
<span class="flex-fill">
|
<span class="flex-fill">
|
||||||
<ng-container
|
<ng-container
|
||||||
*ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container>
|
*ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container>
|
||||||
<!-- <span class="sr-only">{{'nav.expandable-navbar-section-suffix' | translate}}</span>-->
|
|
||||||
</span>
|
</span>
|
||||||
<i class="fas fa-caret-down fa-xs toggle-menu-icon" aria-hidden="true"></i>
|
<i class="fas fa-caret-down fa-xs toggle-menu-icon" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
<div @slide *ngIf="isActive" (click)="deactivateSection($event)"
|
<div *ngIf="(active$ | async).valueOf() === true" (click)="deactivateSection($event)"
|
||||||
[id]="expandableNavbarSectionId(section.id)"
|
[id]="expandableNavbarSectionId()"
|
||||||
role="menu"
|
[dsHoverOutsideOfParentSelector]="'#expandable-navbar-section-' + section.id"
|
||||||
class="dropdown-menu show nav-dropdown-menu m-0 shadow-none border-top-0 px-3 px-md-0 pt-0 pt-md-1">
|
(dsHoverOutside)="deactivateSection($event, false)"
|
||||||
<div *ngFor="let subSection of (subSections$ | async)" class="text-nowrap" role="presentation">
|
role="menu"
|
||||||
<ng-container
|
class="dropdown-menu show nav-dropdown-menu m-0 shadow-none border-top-0 px-3 px-md-0 pt-0 pt-md-1">
|
||||||
*ngComponentOutlet="(sectionMap$ | async).get(subSection.id).component; injector: (sectionMap$ | async).get(subSection.id).injector;"></ng-container>
|
<div @slide role="presentation">
|
||||||
</div>
|
<div *ngFor="let subSection of (subSections$ | async)" class="text-nowrap" role="presentation">
|
||||||
|
<ng-container
|
||||||
|
*ngComponentOutlet="(sectionMap$ | async).get(subSection.id).component; injector: (sectionMap$ | async).get(subSection.id).injector;"></ng-container>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,6 +1,11 @@
|
|||||||
import { Component } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
DebugElement,
|
||||||
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
ComponentFixture,
|
ComponentFixture,
|
||||||
|
fakeAsync,
|
||||||
|
flush,
|
||||||
TestBed,
|
TestBed,
|
||||||
waitForAsync,
|
waitForAsync,
|
||||||
} from '@angular/core/testing';
|
} from '@angular/core/testing';
|
||||||
@@ -10,9 +15,11 @@ import { of as observableOf } from 'rxjs';
|
|||||||
|
|
||||||
import { HostWindowService } from '../../shared/host-window.service';
|
import { HostWindowService } from '../../shared/host-window.service';
|
||||||
import { MenuService } from '../../shared/menu/menu.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 { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub';
|
||||||
import { MenuServiceStub } from '../../shared/testing/menu-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';
|
import { ExpandableNavbarSectionComponent } from './expandable-navbar-section.component';
|
||||||
|
|
||||||
describe('ExpandableNavbarSectionComponent', () => {
|
describe('ExpandableNavbarSectionComponent', () => {
|
||||||
@@ -23,11 +30,17 @@ describe('ExpandableNavbarSectionComponent', () => {
|
|||||||
describe('on larger screens', () => {
|
describe('on larger screens', () => {
|
||||||
beforeEach(waitForAsync(() => {
|
beforeEach(waitForAsync(() => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [NoopAnimationsModule, ExpandableNavbarSectionComponent, TestComponent, VarDirective],
|
imports: [
|
||||||
|
ExpandableNavbarSectionComponent,
|
||||||
|
HoverOutsideDirective,
|
||||||
|
NoopAnimationsModule,
|
||||||
|
TestComponent,
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: 'sectionDataProvider', useValue: {} },
|
{ provide: 'sectionDataProvider', useValue: {} },
|
||||||
{ provide: MenuService, useValue: menuService },
|
{ provide: MenuService, useValue: menuService },
|
||||||
{ provide: HostWindowService, useValue: new HostWindowServiceStub(800) },
|
{ provide: HostWindowService, useValue: new HostWindowServiceStub(800) },
|
||||||
|
TestComponent,
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
}));
|
}));
|
||||||
@@ -41,10 +54,6 @@ describe('ExpandableNavbarSectionComponent', () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when the mouse enters the section header (while inactive)', () => {
|
describe('when the mouse enters the section header (while inactive)', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
spyOn(component, 'onMouseEnter').and.callThrough();
|
spyOn(component, 'onMouseEnter').and.callThrough();
|
||||||
@@ -141,6 +150,8 @@ describe('ExpandableNavbarSectionComponent', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('when spacebar is pressed on section header (while inactive)', () => {
|
describe('when spacebar is pressed on section header (while inactive)', () => {
|
||||||
|
let sidebarToggler: DebugElement;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
spyOn(component, 'toggleSection').and.callThrough();
|
spyOn(component, 'toggleSection').and.callThrough();
|
||||||
spyOn(menuService, 'toggleActiveSection');
|
spyOn(menuService, 'toggleActiveSection');
|
||||||
@@ -149,15 +160,27 @@ describe('ExpandableNavbarSectionComponent', () => {
|
|||||||
component.ngOnInit();
|
component.ngOnInit();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
const sidebarToggler = fixture.debugElement.query(By.css('[data-test="navbar-section-toggler"]'));
|
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: ' ' }));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call toggleSection on the menuService', () => {
|
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(component.toggleSection).toHaveBeenCalled();
|
||||||
expect(menuService.toggleActiveSection).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)', () => {
|
describe('when spacebar is pressed on section header (while active)', () => {
|
||||||
@@ -179,12 +202,116 @@ describe('ExpandableNavbarSectionComponent', () => {
|
|||||||
expect(menuService.toggleActiveSection).toHaveBeenCalled();
|
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', () => {
|
describe('on smaller, mobile screens', () => {
|
||||||
beforeEach(waitForAsync(() => {
|
beforeEach(waitForAsync(() => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [NoopAnimationsModule, ExpandableNavbarSectionComponent, TestComponent, VarDirective],
|
imports: [
|
||||||
|
ExpandableNavbarSectionComponent,
|
||||||
|
HoverOutsideDirective,
|
||||||
|
NoopAnimationsModule,
|
||||||
|
TestComponent,
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: 'sectionDataProvider', useValue: {} },
|
{ provide: 'sectionDataProvider', useValue: {} },
|
||||||
{ provide: MenuService, useValue: menuService },
|
{ provide: MenuService, useValue: menuService },
|
||||||
@@ -253,7 +380,9 @@ describe('ExpandableNavbarSectionComponent', () => {
|
|||||||
// declare a test component
|
// declare a test component
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-test-cmp',
|
selector: 'ds-test-cmp',
|
||||||
template: ``,
|
template: `
|
||||||
|
<a role="menuitem">link</a>
|
||||||
|
`,
|
||||||
standalone: true,
|
standalone: true,
|
||||||
})
|
})
|
||||||
class TestComponent {
|
class TestComponent {
|
||||||
|
@@ -5,10 +5,12 @@ import {
|
|||||||
NgIf,
|
NgIf,
|
||||||
} from '@angular/common';
|
} from '@angular/common';
|
||||||
import {
|
import {
|
||||||
|
AfterViewChecked,
|
||||||
Component,
|
Component,
|
||||||
HostListener,
|
HostListener,
|
||||||
Inject,
|
Inject,
|
||||||
Injector,
|
Injector,
|
||||||
|
OnDestroy,
|
||||||
OnInit,
|
OnInit,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { RouterLinkActive } from '@angular/router';
|
import { RouterLinkActive } from '@angular/router';
|
||||||
@@ -19,7 +21,8 @@ import { slide } from '../../shared/animations/slide';
|
|||||||
import { HostWindowService } from '../../shared/host-window.service';
|
import { HostWindowService } from '../../shared/host-window.service';
|
||||||
import { MenuService } from '../../shared/menu/menu.service';
|
import { MenuService } from '../../shared/menu/menu.service';
|
||||||
import { MenuID } from '../../shared/menu/menu-id.model';
|
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';
|
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'],
|
styleUrls: ['./expandable-navbar-section.component.scss'],
|
||||||
animations: [slide],
|
animations: [slide],
|
||||||
standalone: true,
|
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
|
* This section resides in the Public Navbar
|
||||||
*/
|
*/
|
||||||
@@ -44,6 +55,11 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp
|
|||||||
*/
|
*/
|
||||||
mouseEntered = false;
|
mouseEntered = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the section was expanded
|
||||||
|
*/
|
||||||
|
focusOnFirstChildSection = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True if screen size was small before a resize event
|
* True if screen size was small before a resize event
|
||||||
*/
|
*/
|
||||||
@@ -54,6 +70,18 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp
|
|||||||
*/
|
*/
|
||||||
isMobile$: Observable<boolean>;
|
isMobile$: Observable<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<HTMLElement>;
|
||||||
|
|
||||||
@HostListener('window:resize', ['$event'])
|
@HostListener('window:resize', ['$event'])
|
||||||
onResize() {
|
onResize() {
|
||||||
this.isMobile$.pipe(
|
this.isMobile$.pipe(
|
||||||
@@ -68,29 +96,80 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(@Inject('sectionDataProvider') menuSection,
|
constructor(
|
||||||
protected menuService: MenuService,
|
@Inject('sectionDataProvider') public section: MenuSection,
|
||||||
protected injector: Injector,
|
protected menuService: MenuService,
|
||||||
private windowService: HostWindowService,
|
protected injector: Injector,
|
||||||
|
protected windowService: HostWindowService,
|
||||||
) {
|
) {
|
||||||
super(menuSection, menuService, injector);
|
super(section, menuService, injector);
|
||||||
this.isMobile$ = this.windowService.isMobile();
|
this.isMobile$ = this.windowService.isMobile();
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
super.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
|
* When the mouse enters the section toggler activate the menu section
|
||||||
* @param $event
|
* @param $event
|
||||||
* @param isActive
|
|
||||||
*/
|
*/
|
||||||
onMouseEnter($event: Event, isActive: boolean) {
|
onMouseEnter($event: Event): void {
|
||||||
this.isMobile$.pipe(
|
this.isMobile$.pipe(
|
||||||
first(),
|
first(),
|
||||||
).subscribe((isMobile) => {
|
).subscribe((isMobile) => {
|
||||||
if (!isMobile && !isActive && !this.mouseEntered) {
|
if (!isMobile && !this.active$.value && !this.mouseEntered) {
|
||||||
this.activateSection($event);
|
this.activateSection($event);
|
||||||
}
|
}
|
||||||
this.mouseEntered = true;
|
this.mouseEntered = true;
|
||||||
@@ -100,13 +179,12 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp
|
|||||||
/**
|
/**
|
||||||
* When the mouse leaves the section toggler deactivate the menu section
|
* When the mouse leaves the section toggler deactivate the menu section
|
||||||
* @param $event
|
* @param $event
|
||||||
* @param isActive
|
|
||||||
*/
|
*/
|
||||||
onMouseLeave($event: Event, isActive: boolean) {
|
onMouseLeave($event: Event): void {
|
||||||
this.isMobile$.pipe(
|
this.isMobile$.pipe(
|
||||||
first(),
|
first(),
|
||||||
).subscribe((isMobile) => {
|
).subscribe((isMobile) => {
|
||||||
if (!isMobile && isActive && this.mouseEntered) {
|
if (!isMobile && this.active$.value && this.mouseEntered) {
|
||||||
this.deactivateSection($event);
|
this.deactivateSection($event);
|
||||||
}
|
}
|
||||||
this.mouseEntered = false;
|
this.mouseEntered = false;
|
||||||
@@ -115,9 +193,60 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* returns the ID of the DOM element representing the navbar section
|
* returns the ID of the DOM element representing the navbar section
|
||||||
* @param sectionId
|
|
||||||
*/
|
*/
|
||||||
expandableNavbarSectionId(sectionId: string) {
|
expandableNavbarSectionId(): string {
|
||||||
return `expandable-navbar-section-${sectionId}-dropdown`;
|
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<Element> = 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -5,9 +5,10 @@
|
|||||||
[ngClass]="getClass('element', 'control')">
|
[ngClass]="getClass('element', 'control')">
|
||||||
|
|
||||||
<!-- Draggable Container -->
|
<!-- 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 -->
|
<!-- 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"
|
role="group"
|
||||||
[formGroupName]="groupModel.index"
|
[formGroupName]="groupModel.index"
|
||||||
[ngClass]="[getClass('element', 'group'), getClass('grid', 'group')]"
|
[ngClass]="[getClass('element', 'group'), getClass('grid', 'group')]"
|
||||||
@@ -16,7 +17,14 @@
|
|||||||
[cdkDragPreviewClass]="'ds-submission-reorder-dragging'"
|
[cdkDragPreviewClass]="'ds-submission-reorder-dragging'"
|
||||||
[class.grey-background]="model.isInlineGroupArray">
|
[class.grey-background]="model.isInlineGroupArray">
|
||||||
<!-- Item content -->
|
<!-- 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>
|
<i class="drag-icon fas fa-grip-vertical fa-fw" [class.drag-disable]="dragDisabled" ></i>
|
||||||
</div>
|
</div>
|
||||||
<ng-container *ngTemplateOutlet="startTemplate?.templateRef; context: groupModel"></ng-container>
|
<ng-container *ngTemplateOutlet="startTemplate?.templateRef; context: groupModel"></ng-container>
|
||||||
|
@@ -17,7 +17,6 @@
|
|||||||
margin-right: calc(-0.5 * var(--bs-spacer));
|
margin-right: calc(-0.5 * var(--bs-spacer));
|
||||||
padding-right: calc(0.5 * var(--bs-spacer));
|
padding-right: calc(0.5 * var(--bs-spacer));
|
||||||
.drag-icon {
|
.drag-icon {
|
||||||
visibility: hidden;
|
|
||||||
width: calc(2 * var(--bs-spacer));
|
width: calc(2 * var(--bs-spacer));
|
||||||
color: var(--bs-gray-600);
|
color: var(--bs-gray-600);
|
||||||
margin: var(--bs-btn-padding-y) 0;
|
margin: var(--bs-btn-padding-y) 0;
|
||||||
@@ -27,9 +26,6 @@
|
|||||||
|
|
||||||
&:hover, &:focus {
|
&:hover, &:focus {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
.drag-icon {
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -40,18 +36,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
.drag-icon {
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.cdk-drop-list-dragging {
|
.cdk-drop-list-dragging {
|
||||||
.drag-handle {
|
.drag-handle {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
.drag-icon {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,3 +53,9 @@
|
|||||||
.cdk-drag-placeholder {
|
.cdk-drag-placeholder {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::ng-deep {
|
||||||
|
.sorting-with-keyboard input {
|
||||||
|
background-color: var(--bs-gray-400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
@@ -32,9 +32,14 @@ import {
|
|||||||
DynamicFormValidationService,
|
DynamicFormValidationService,
|
||||||
DynamicTemplateDirective,
|
DynamicTemplateDirective,
|
||||||
} from '@ng-dynamic-forms/core';
|
} from '@ng-dynamic-forms/core';
|
||||||
|
import {
|
||||||
|
TranslateModule,
|
||||||
|
TranslateService,
|
||||||
|
} from '@ngx-translate/core';
|
||||||
|
|
||||||
import { Relationship } from '../../../../../../core/shared/item-relationships/relationship.model';
|
import { Relationship } from '../../../../../../core/shared/item-relationships/relationship.model';
|
||||||
import { hasValue } from '../../../../../empty.util';
|
import { hasValue } from '../../../../../empty.util';
|
||||||
|
import { LiveRegionService } from '../../../../../live-region/live-region.service';
|
||||||
import { DsDynamicFormControlContainerComponent } from '../../ds-dynamic-form-control-container.component';
|
import { DsDynamicFormControlContainerComponent } from '../../ds-dynamic-form-control-container.component';
|
||||||
import { DynamicRowArrayModel } from '../ds-dynamic-row-array-model';
|
import { DynamicRowArrayModel } from '../ds-dynamic-row-array-model';
|
||||||
|
|
||||||
@@ -51,6 +56,7 @@ import { DynamicRowArrayModel } from '../ds-dynamic-row-array-model';
|
|||||||
CdkDragHandle,
|
CdkDragHandle,
|
||||||
forwardRef(() => DsDynamicFormControlContainerComponent),
|
forwardRef(() => DsDynamicFormControlContainerComponent),
|
||||||
NgTemplateOutlet,
|
NgTemplateOutlet,
|
||||||
|
TranslateModule,
|
||||||
],
|
],
|
||||||
standalone: true,
|
standalone: true,
|
||||||
})
|
})
|
||||||
@@ -64,6 +70,9 @@ export class DsDynamicFormArrayComponent extends DynamicFormArrayComponent {
|
|||||||
@Input() model: DynamicRowArrayModel;// DynamicRow?
|
@Input() model: DynamicRowArrayModel;// DynamicRow?
|
||||||
@Input() templates: QueryList<DynamicTemplateDirective> | undefined;
|
@Input() templates: QueryList<DynamicTemplateDirective> | undefined;
|
||||||
|
|
||||||
|
elementBeingSorted: HTMLElement;
|
||||||
|
elementBeingSortedStartingIndex: number;
|
||||||
|
|
||||||
/* eslint-disable @angular-eslint/no-output-rename */
|
/* eslint-disable @angular-eslint/no-output-rename */
|
||||||
@Output('dfBlur') blur: EventEmitter<DynamicFormControlEvent> = new EventEmitter<DynamicFormControlEvent>();
|
@Output('dfBlur') blur: EventEmitter<DynamicFormControlEvent> = new EventEmitter<DynamicFormControlEvent>();
|
||||||
@Output('dfChange') change: EventEmitter<DynamicFormControlEvent> = new EventEmitter<DynamicFormControlEvent>();
|
@Output('dfChange') change: EventEmitter<DynamicFormControlEvent> = new EventEmitter<DynamicFormControlEvent>();
|
||||||
@@ -74,6 +83,8 @@ export class DsDynamicFormArrayComponent extends DynamicFormArrayComponent {
|
|||||||
|
|
||||||
constructor(protected layoutService: DynamicFormLayoutService,
|
constructor(protected layoutService: DynamicFormLayoutService,
|
||||||
protected validationService: DynamicFormValidationService,
|
protected validationService: DynamicFormValidationService,
|
||||||
|
protected liveRegionService: LiveRegionService,
|
||||||
|
protected translateService: TranslateService,
|
||||||
) {
|
) {
|
||||||
super(layoutService, validationService);
|
super(layoutService, validationService);
|
||||||
}
|
}
|
||||||
@@ -127,4 +138,149 @@ export class DsDynamicFormArrayComponent extends DynamicFormArrayComponent {
|
|||||||
}
|
}
|
||||||
return this.control.get([groupModel.startingIndex]);
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -37,9 +37,9 @@ import { MenuSection } from '../menu-section.model';
|
|||||||
export class MenuSectionComponent implements OnInit, OnDestroy {
|
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<boolean>;
|
active$: BehaviorSubject<boolean> = new BehaviorSubject(false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The ID of the menu this section resides in
|
* 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
|
* Set initial values for instance variables
|
||||||
*/
|
*/
|
||||||
ngOnInit(): void {
|
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();
|
this.initializeInjectorData();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,9 +94,12 @@ export class MenuSectionComponent implements OnInit, OnDestroy {
|
|||||||
/**
|
/**
|
||||||
* Activate this section
|
* Activate this section
|
||||||
* @param {Event} event The user event that triggered this method
|
* @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) {
|
activateSection(event: Event, skipEvent = true): void {
|
||||||
event.preventDefault();
|
if (skipEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
if (!this.section.model?.disabled) {
|
if (!this.section.model?.disabled) {
|
||||||
this.menuService.activateSection(this.menuID, this.section.id);
|
this.menuService.activateSection(this.menuID, this.section.id);
|
||||||
}
|
}
|
||||||
@@ -100,10 +107,14 @@ export class MenuSectionComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Deactivate this section
|
* Deactivate this section
|
||||||
|
*
|
||||||
* @param {Event} event The user event that triggered this method
|
* @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) {
|
deactivateSection(event: Event, skipEvent = true): void {
|
||||||
event.preventDefault();
|
if (skipEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
this.menuService.deactivateSection(this.menuID, this.section.id);
|
this.menuService.deactivateSection(this.menuID, this.section.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
53
src/app/shared/utils/hover-outside.directive.ts
Normal file
53
src/app/shared/utils/hover-outside.directive.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -6771,4 +6771,14 @@
|
|||||||
"forgot-email.form.aria.label": "Enter your e-mail address",
|
"forgot-email.form.aria.label": "Enter your e-mail address",
|
||||||
|
|
||||||
"search-facet-option.update.announcement": "The page will be reloaded. Filter {{ filter }} is selected.",
|
"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",
|
||||||
}
|
}
|
||||||
|
@@ -9,11 +9,8 @@ import { RouterLinkActive } from '@angular/router';
|
|||||||
|
|
||||||
import { ExpandableNavbarSectionComponent as BaseComponent } from '../../../../../app/navbar/expandable-navbar-section/expandable-navbar-section.component';
|
import { ExpandableNavbarSectionComponent as BaseComponent } from '../../../../../app/navbar/expandable-navbar-section/expandable-navbar-section.component';
|
||||||
import { slide } from '../../../../../app/shared/animations/slide';
|
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({
|
@Component({
|
||||||
selector: 'ds-themed-expandable-navbar-section',
|
selector: 'ds-themed-expandable-navbar-section',
|
||||||
// templateUrl: './expandable-navbar-section.component.html',
|
// 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'],
|
styleUrls: ['../../../../../app/navbar/expandable-navbar-section/expandable-navbar-section.component.scss'],
|
||||||
animations: [slide],
|
animations: [slide],
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [VarDirective, RouterLinkActive, NgComponentOutlet, NgIf, NgFor, AsyncPipe],
|
imports: [
|
||||||
|
AsyncPipe,
|
||||||
|
HoverOutsideDirective,
|
||||||
|
NgComponentOutlet,
|
||||||
|
NgFor,
|
||||||
|
NgIf,
|
||||||
|
RouterLinkActive,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class ExpandableNavbarSectionComponent extends BaseComponent {
|
export class ExpandableNavbarSectionComponent extends BaseComponent {
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user