Merge branch 'main' into task/main/DURACOM-288

# Conflicts:
#	config/config.example.yml
This commit is contained in:
Giuseppe Digilio
2025-01-30 20:44:26 +01:00
13 changed files with 742 additions and 85 deletions

View File

@@ -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.

View File

@@ -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))),
); );
} }

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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;
}
} }
} }

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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();
});
});

View File

@@ -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,
}));
}
}
} }

View File

@@ -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);
} }

View 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);
}
}
}

View File

@@ -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",
} }

View File

@@ -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 {
} }