diff --git a/projects/angular/clarity.api.md b/projects/angular/clarity.api.md index 3436c8d4bc..7760172c44 100644 --- a/projects/angular/clarity.api.md +++ b/projects/angular/clarity.api.md @@ -2468,6 +2468,8 @@ export class ClrHeader implements OnDestroy { // (undocumented) ngOnDestroy(): void; // (undocumented) + openNav(navLevel: number): void; + // (undocumented) openNavLevel: number; // (undocumented) resetNavTriggers(): void; @@ -2479,7 +2481,7 @@ export class ClrHeader implements OnDestroy { get responsiveNavCommonString(): string; // (undocumented) get responsiveOverflowCommonString(): string; - // (undocumented) + // @deprecated (undocumented) toggleNav(navLevel: number): void; // (undocumented) static ɵcmp: i0.ɵɵComponentDeclaration; @@ -2998,29 +3000,48 @@ export class ClrNavigationModule { static ɵmod: i0.ɵɵNgModuleDeclaration; } +// Warning: (ae-forgotten-export) The symbol "FocusTrap" needs to be exported by the entry point index.d.ts +// // @public (undocumented) -export class ClrNavLevel implements OnInit { - constructor(responsiveNavService: ResponsiveNavigationService, elementRef: ElementRef); +export class ClrNavLevel extends FocusTrap implements OnInit { + // Warning: (ae-forgotten-export) The symbol "FocusTrapElement" needs to be exported by the entry point index.d.ts + constructor(platformId: any, responsiveNavService: ResponsiveNavigationService, elementRef: ElementRef, renderer: Renderer2, injector: Injector); // (undocumented) addNavClass(level: number): void; // (undocumented) close(): void; // (undocumented) + closeButtonAriaLabel: string; + // (undocumented) + protected hideCloseButton(): void; + // (undocumented) + protected hideNavigation(): void; + // (undocumented) + get isOpen(): boolean; + // (undocumented) get level(): number; // (undocumented) _level: number; // (undocumented) + ngAfterViewInit(): void; + // (undocumented) ngOnDestroy(): void; // (undocumented) ngOnInit(): void; // (undocumented) onMouseClick(target: any): void; // (undocumented) + onResize(event: Event): void; + // (undocumented) open(): void; // (undocumented) get responsiveNavCodes(): ResponsiveNavCodes; // (undocumented) - static ɵdir: i0.ɵɵDirectiveDeclaration; + protected showCloseButton(): void; + // (undocumented) + protected showNavigation(): void; + // (undocumented) + static ɵdir: i0.ɵɵDirectiveDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } diff --git a/projects/angular/src/layout/nav/_responsive-nav.clarity.scss b/projects/angular/src/layout/nav/_responsive-nav.clarity.scss index 13702a38a1..a3387e7074 100644 --- a/projects/angular/src/layout/nav/_responsive-nav.clarity.scss +++ b/projects/angular/src/layout/nav/_responsive-nav.clarity.scss @@ -52,6 +52,12 @@ } } +@mixin clr-nav-close-trigger-animation() { + left: $clr_baselineRem_0_125; + transform-origin: 9%; + transition: $clr-trigger-animation; +} + @mixin overflow-menu-trigger-animation() { & > span { background: transparent; @@ -78,12 +84,24 @@ @import './properties.responsive-nav'; @include exports('responsive-nav.clarity') { + .clr-nav-close { + margin: 0.75rem; + + &:hover, + &:focus { + --color: var(--clr-responsive-nav-trigger-bg-color); + } + + --color: var(--clr-color-neutral-500); + } + .header-hamburger-trigger, .header-overflow-trigger { display: none; } - .header-hamburger-trigger { + .header-hamburger-trigger, + .clr-nav-close { $trigger-span-psuedo-positioning: -1 * $clr_baselineRem_7px; & > span, @@ -131,7 +149,8 @@ } } - .header-overflow-trigger { + .header-overflow-trigger, + .clr-nav-close { $overflow-trigger-psuedo-positioning: -1 * $clr_baselineRem_8px; & > span, @@ -221,6 +240,11 @@ transition: $clr-sliding-panel-animation; } + .sidenav.clr-nav-level-2 { + // This is to prevent the close button to be hidden by the sidenav content + overflow: inherit; + } + .subnav.clr-nav-level-1, .sub-nav.clr-nav-level-1, .subnav.clr-nav-level-2, @@ -451,7 +475,7 @@ } } - .header-hamburger-trigger { + .clr-nav-close { @include menu-trigger(); @include hamburger-menu-trigger-animation(); } @@ -487,7 +511,7 @@ padding-top: $clr_baselineRem_1; } - .header-overflow-trigger { + .clr-nav-close { @include menu-trigger(0, auto, (-1 * $clr-trigger-position)); @include overflow-menu-trigger-animation(); } @@ -562,7 +586,7 @@ max-width: $clr-sliding-panel-width-sm; } - .header-hamburger-trigger { + .clr-nav-close { @include menu-trigger(auto, 0, $clr-trigger-position-sm); } } @@ -577,7 +601,7 @@ max-width: $clr-sliding-panel-width-sm; } - .header-overflow-trigger { + .clr-nav-close { @include menu-trigger(0, auto, (-1 * $clr-trigger-position-sm)); } } diff --git a/projects/angular/src/layout/nav/header.ts b/projects/angular/src/layout/nav/header.ts index 752778b2b3..b9328faa9c 100644 --- a/projects/angular/src/layout/nav/header.ts +++ b/projects/angular/src/layout/nav/header.ts @@ -10,6 +10,7 @@ import { Subscription } from 'rxjs'; import { ResponsiveNavigationService } from './providers/responsive-navigation.service'; import { ResponsiveNavCodes } from './responsive-nav-codes'; import { ClrCommonStringsService } from '../../utils/i18n/common-strings.service'; +import { filter } from 'rxjs/operators'; @Component({ selector: 'clr-header', @@ -20,7 +21,7 @@ import { ClrCommonStringsService } from '../../utils/i18n/common-strings.service class="header-hamburger-trigger" [attr.aria-label]="responsiveNavCommonString" [attr.aria-expanded]="openNavLevel === 1 ? 'true' : 'false'" - (click)="toggleNav(responsiveNavCodes.NAV_LEVEL_1)" + (click)="openNav(responsiveNavCodes.NAV_LEVEL_1)" > @@ -31,7 +32,7 @@ import { ClrCommonStringsService } from '../../utils/i18n/common-strings.service class="header-overflow-trigger" [attr.aria-label]="responsiveOverflowCommonString" [attr.aria-expanded]="openNavLevel === 2 ? 'true' : 'false'" - (click)="toggleNav(responsiveNavCodes.NAV_LEVEL_2)" + (click)="openNav(responsiveNavCodes.NAV_LEVEL_2)" > @@ -55,6 +56,19 @@ export class ClrHeader implements OnDestroy { this.initializeNavTriggers(navLevelList); }, }); + + this._subscription.add( + this.responsiveNavService.navControl + .pipe( + filter( + ({ controlCode }) => + controlCode === ResponsiveNavCodes.NAV_CLOSE || controlCode === ResponsiveNavCodes.NAV_CLOSE_ALL + ) + ) + .subscribe(() => { + this.openNavLevel = null; + }) + ); } get responsiveNavCommonString() { @@ -102,10 +116,23 @@ export class ClrHeader implements OnDestroy { this.responsiveNavService.closeAllNavs(); } - // toggles the nav that is open + /** + * @deprecated Will be removed in with @clr/angular v15.0.0 + * + * Use `openNav(navLevel)` instead to open the navigation and ResponsiveNavService to close it. + */ toggleNav(navLevel: number) { - this.openNavLevel = this.openNavLevel === navLevel ? null : navLevel; - this.responsiveNavService.sendControlMessage(ResponsiveNavCodes.NAV_TOGGLE, navLevel); + if (this.openNavLevel === navLevel) { + this.responsiveNavService.sendControlMessage(ResponsiveNavCodes.NAV_CLOSE, navLevel); + return; + } + + this.openNav(navLevel); + } + + openNav(navLevel: number) { + this.openNavLevel = navLevel; + this.responsiveNavService.sendControlMessage(ResponsiveNavCodes.NAV_OPEN, navLevel); } ngOnDestroy() { diff --git a/projects/angular/src/layout/nav/nav-level.spec.ts b/projects/angular/src/layout/nav/nav-level.spec.ts index adf5d42e99..a480e1ee83 100644 --- a/projects/angular/src/layout/nav/nav-level.spec.ts +++ b/projects/angular/src/layout/nav/nav-level.spec.ts @@ -4,54 +4,191 @@ * The full license information can be found in LICENSE in the root directory of this project. */ +import { Component } from '@angular/core'; +import { fakeAsync } from '@angular/core/testing'; +import { CdsInternalCloseButton } from '@cds/core/internal-components/close-button'; +import { LARGE_BREAKPOINT } from 'src/utils/breakpoints/breakpoints'; +import { spec } from '../../utils/testing/helpers.spec'; import { ClrNavLevel } from './nav-level'; +import { ClrNavigationModule } from './navigation.module'; import { ResponsiveNavigationService } from './providers/responsive-navigation.service'; import { ResponsiveNavCodes } from './responsive-nav-codes'; -describe('NavLevelDirective', () => { - let service: ResponsiveNavigationService; - let navLevel: ClrNavLevel; +@Component({ + template: ` `, +}) +class TestComponent {} - beforeEach(() => { - service = new ResponsiveNavigationService(); - navLevel = new ClrNavLevel(service, null); // null because we are just testing the directive functions - navLevel._level = 1; - }); +describe('NavLevelDirective', function () { + spec(ClrNavLevel, TestComponent, ClrNavigationModule, null, true); - it('#level is set to 1', () => { + it('#level is set to 1', function () { + const navLevel = this.clarityDirective; expect(navLevel.level).toBe(ResponsiveNavCodes.NAV_LEVEL_1); }); - it('#level is set to 2', () => { + it('should set [attr.hidden] when called hideNavigation()', function () { + const navLevel = this.clarityDirective; + navLevel.hideNavigation(); + const element = this.fixture.nativeElement.querySelector('nav'); + expect(element.getAttribute('hidden')).toBe('true'); + }); + + it('should remove [attr.hidden] when called showNavigation()', function () { + const navLevel = this.clarityDirective; + const element = this.fixture.nativeElement.querySelector('nav'); + navLevel.hideNavigation(); + expect(element.getAttribute('hidden')).toBe('true'); + navLevel.showNavigation(); + expect(element.getAttribute('hidden')).toBeNull(); + }); + + it('should set [attr.hidden] when called hideCloseButton()', function () { + const navLevel = this.clarityDirective; + const element = this.fixture.nativeElement.querySelector('cds-internal-close-button'); + navLevel.hideCloseButton(); + expect(element.getAttribute('hidden')).toBe('true'); + }); + + it('should remove [attr.hidden] when called showCloseButton()', function () { + const navLevel = this.clarityDirective; + const element = this.fixture.nativeElement.querySelector('cds-internal-close-button'); + navLevel.hideCloseButton(); + expect(element.getAttribute('hidden')).toBe('true'); + navLevel.showCloseButton(); + expect(element.getAttribute('hidden')).toBeNull(); + }); + + it('should hide the navigation when document client width is smaller than LARGE_BREAKPOINT', function () { + this.clarityDirective.onResize({ + target: { + innerWidth: LARGE_BREAKPOINT - 1, + }, + }); + + expect(this.clarityDirective.isOpen).toBe(false); + }); + + it('#level is set to 2', fakeAsync(function () { + const navLevel = this.clarityDirective; navLevel._level = 2; expect(navLevel.level).toBe(ResponsiveNavCodes.NAV_LEVEL_2); - }); + })); + + describe('ResponsiveNavLevel intergration:', function () { + it('#registers nav on init. sends the registration code on registerNavSubject in the service', function () { + const service = new ResponsiveNavigationService(); - it('#registers nav on init. sends the registration code on registerNavSubject in the service', () => { - service.registerNav(ResponsiveNavCodes.NAV_LEVEL_1); - service.registeredNavs.subscribe(navArray => { - expect(navArray[0]).toBe(ResponsiveNavCodes.NAV_LEVEL_1); + service.registerNav(ResponsiveNavCodes.NAV_LEVEL_1); + service.registeredNavs.subscribe(navArray => { + expect(navArray[0]).toBe(ResponsiveNavCodes.NAV_LEVEL_1); + }); + }); + + it('#sends the open code on controlNavSubject in the service when open() is called', function () { + const navLevel = this.clarityDirective; + const service = this.clarityDirective.responsiveNavService; + service.navControl.subscribe(controlMessage => { + expect(controlMessage.controlCode).toBe(ResponsiveNavCodes.NAV_OPEN); + }); + navLevel.open(); + }); + + it('#sends the close code on controlNavSubject when close() is called', function () { + const navLevel = this.clarityDirective; + const service = this.clarityDirective.responsiveNavService; + service.navControl.subscribe(controlMessage => { + expect(controlMessage.controlCode).toBe(ResponsiveNavCodes.NAV_CLOSE); + }); + navLevel.close(); + }); + + it('#unregisters itself from the registerNavSubject when ngOnDestroy() is called', function () { + const navLevel = this.clarityDirective; + const service = this.clarityDirective.responsiveNavService; + navLevel.ngOnDestroy(); + service.registeredNavs.subscribe(navArray => { + expect(navArray.length).toBe(0); + }); }); }); - it('#sends the open code on controlNavSubject in the service when open() is called', () => { - navLevel.open(); - service.navControl.subscribe(controlMessage => { - expect(controlMessage.controlCode).toBe(ResponsiveNavCodes.NAV_OPEN); + describe('Open/Close functionality: ', function () { + it('should be open', function () { + this.clarityDirective.open(); + expect(this.clarityDirective.isOpen).toBe(true); + }); + + describe('Open', function () { + it('should not be open by default', function () { + expect(this.clarityDirective.isOpen).toBe(false); + }); + + it('should call showNavigation()', function () { + spyOn(this.clarityDirective, 'showNavigation'); + this.clarityDirective.open(); + expect(this.clarityDirective.showNavigation).toHaveBeenCalled(); + }); + + it('should call enableFocusTrap()', function () { + spyOn(this.clarityDirective, 'enableFocusTrap'); + this.clarityDirective.open(); + expect(this.clarityDirective.enableFocusTrap).toHaveBeenCalled(); + }); + + it('should call showCloseButton()', function () { + spyOn(this.clarityDirective, 'showCloseButton'); + this.clarityDirective.open(); + expect(this.clarityDirective.showCloseButton).toHaveBeenCalled(); + }); + }); + + it('should be closed', function () { + this.clarityDirective.close(); + expect(this.clarityDirective.isOpen).toBe(false); + }); + + describe('Close', function () { + it('should be closed by default', function () { + expect(this.clarityDirective.isOpen).toBe(false); + }); + it('should call hideNavigation()', function () { + const spy = spyOn(this.clarityDirective, 'hideNavigation'); + this.clarityDirective.close(); + expect(spy).toHaveBeenCalled(); + }); + it('should call removeFocusTrap()', function () { + const spy = spyOn(this.clarityDirective, 'removeFocusTrap'); + this.clarityDirective.close(); + expect(spy).toHaveBeenCalled(); + }); + it('should call hideCloseButton()', function () { + const spy = spyOn(this.clarityDirective, 'hideCloseButton'); + this.clarityDirective.close(); + expect(spy).toHaveBeenCalled(); + }); }); }); - it('#sends the close code on controlNavSubject when close() is called', () => { - navLevel.close(); - service.navControl.subscribe(controlMessage => { - expect(controlMessage.controlCode).toBe(ResponsiveNavCodes.NAV_CLOSE); + describe('ngAfterViewInit: ', function () { + it('should add close button to the host element', function () { + const closeButtons = this.fixture.elementRef.nativeElement.querySelectorAll('cds-internal-close-button'); + expect(closeButtons.length).toBe(1); + expect(closeButtons[0]).toBeInstanceOf(CdsInternalCloseButton); }); }); - it('#unregisters itself from the registerNavSubject when ngOnDestroy() is called', () => { - navLevel.ngOnDestroy(); - service.registeredNavs.subscribe(navArray => { - expect(navArray.length).toBe(0); + describe('Close button:', function () { + it('should hide navigation when the close button is clicked', function () { + /** + * Tried to spyOn on the 'close` function but since we are using `this.close.bind(this)` the spy is not working. + */ + const closeButton = this.fixture.elementRef.nativeElement.querySelector('cds-internal-close-button'); + this.clarityDirective._isOpen = false; + closeButton.click(); + expect(this.clarityDirective.isOpen).toBe(false); }); }); }); diff --git a/projects/angular/src/layout/nav/nav-level.ts b/projects/angular/src/layout/nav/nav-level.ts index 255ba8d644..dc51b37fd2 100644 --- a/projects/angular/src/layout/nav/nav-level.ts +++ b/projects/angular/src/layout/nav/nav-level.ts @@ -4,18 +4,97 @@ * The full license information can be found in LICENSE in the root directory of this project. */ -import { Directive, ElementRef, HostListener, Input, OnInit } from '@angular/core'; +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; +import { + Directive, + ElementRef, + HostListener, + Inject, + Injector, + Input, + OnInit, + PLATFORM_ID, + Renderer2, +} from '@angular/core'; +import { filter } from 'rxjs/operators'; import { ResponsiveNavigationService } from './providers/responsive-navigation.service'; import { ResponsiveNavCodes } from './responsive-nav-codes'; +import { Subscription } from 'rxjs'; +import { LARGE_BREAKPOINT } from '../../utils/breakpoints/breakpoints'; +import { FocusTrap, FocusTrapElement } from '../../utils/focus-trap/focus-trap'; +import { commonStringsDefault } from '../../utils'; +import '@cds/core/internal-components/close-button/register.js'; + +const createCdsCloseButton = (document: Document, ariaLabel: string) => { + const cdsCloseButton = document.createElement('cds-internal-close-button'); + cdsCloseButton.setAttribute('icon-size', '32'); + cdsCloseButton.setAttribute('aria-label', ariaLabel); + cdsCloseButton.setAttribute('type', 'button'); + /** + * The button is hidden by default based on our Desktop-first approach. + */ + cdsCloseButton.setAttribute('hidden', 'true'); + cdsCloseButton.className = 'clr-nav-close'; + return cdsCloseButton; +}; @Directive({ selector: '[clr-nav-level]' }) -export class ClrNavLevel implements OnInit { +export class ClrNavLevel extends FocusTrap implements OnInit { @Input('clr-nav-level') _level: number; + @Input('closeAriaLabel') + closeButtonAriaLabel: string; + + private _subscription: Subscription; + + private _isOpen = false; + + constructor( + @Inject(PLATFORM_ID) platformId: any, + private responsiveNavService: ResponsiveNavigationService, + private elementRef: ElementRef, + renderer: Renderer2, + injector: Injector + ) { + super(renderer, injector, platformId, elementRef.nativeElement); + + if (isPlatformBrowser(platformId)) { + this._document = injector.get(DOCUMENT); + } + + this._subscription = responsiveNavService.navControl + .pipe( + filter(x => x.navLevel === this.level), + filter( + ({ controlCode }) => + (controlCode === ResponsiveNavCodes.NAV_OPEN && !this.isOpen) || + (controlCode === ResponsiveNavCodes.NAV_CLOSE && this.isOpen) + ) + ) + .subscribe(({ controlCode }) => { + if (controlCode === ResponsiveNavCodes.NAV_OPEN) { + this.open(); + return; + } + + this.close(); + }); - constructor(private responsiveNavService: ResponsiveNavigationService, private elementRef: ElementRef) {} + this._subscription.add( + responsiveNavService.navControl + .pipe(filter(({ controlCode }) => controlCode === ResponsiveNavCodes.NAV_CLOSE_ALL)) + .subscribe(() => this.close()) + ); + } ngOnInit() { + if (!this.closeButtonAriaLabel) { + this.closeButtonAriaLabel = + this._level === ResponsiveNavCodes.NAV_LEVEL_1 + ? commonStringsDefault.responsiveNavToggleClose + : commonStringsDefault.responsiveNavOverflowClose; + } + if (this.level !== ResponsiveNavCodes.NAV_LEVEL_1 && this.level !== ResponsiveNavCodes.NAV_LEVEL_2) { console.error('Nav Level can only be 1 or 2'); return; @@ -24,6 +103,22 @@ export class ClrNavLevel implements OnInit { this.addNavClass(this.level); } + ngAfterViewInit() { + const closeButton = createCdsCloseButton(this._document, this.closeButtonAriaLabel); + this.renderer.listen(closeButton, 'click', this.close.bind(this)); + this.renderer.insertBefore(this.elementRef.nativeElement, closeButton, this.elementRef.nativeElement.firstChild); // Adding the button at the top of the nav + + if (this._document.body.clientWidth < LARGE_BREAKPOINT) { + /** + * Close if the document body is smaller than the large breakpoint for example: + * - Refreshing the page + * - Browser window size is changed when opening the applicaiton + * - Browser zoom is turned on and zoomed to a size that makes the document smaller than the large breakpoint + */ + this.close(); + } + } + addNavClass(level: number) { const navHostClassList = this.elementRef.nativeElement.classList; if (level === ResponsiveNavCodes.NAV_LEVEL_1) { @@ -42,11 +137,35 @@ export class ClrNavLevel implements OnInit { return ResponsiveNavCodes; } + get isOpen(): boolean { + return this._isOpen; + } + + @HostListener('window:resize', ['$event']) + onResize(event: Event) { + const target = event.target as Window; + + if (target.innerWidth < LARGE_BREAKPOINT && this.isOpen) { + this.close(); + return; + } + + this.showNavigation(); + } + open(): void { + this._isOpen = true; + this.showNavigation(); + this.enableFocusTrap(); + this.showCloseButton(); this.responsiveNavService.sendControlMessage(ResponsiveNavCodes.NAV_OPEN, this.level); } close(): void { + this._isOpen = false; + this.hideNavigation(); + this.removeFocusTrap(); + this.hideCloseButton(); this.responsiveNavService.sendControlMessage(ResponsiveNavCodes.NAV_CLOSE, this.level); } @@ -72,7 +191,24 @@ export class ClrNavLevel implements OnInit { } } + protected hideNavigation() { + this.renderer.setAttribute(this.elementRef.nativeElement, 'hidden', 'true'); + } + + protected showNavigation() { + this.renderer.removeAttribute(this.elementRef.nativeElement, 'hidden'); + } + + protected hideCloseButton() { + this.renderer.setAttribute(this.elementRef.nativeElement.querySelector('.clr-nav-close'), 'hidden', 'true'); + } + + protected showCloseButton() { + this.renderer.removeAttribute(this.elementRef.nativeElement.querySelector('.clr-nav-close'), 'hidden'); + } + ngOnDestroy() { this.responsiveNavService.unregisterNav(this.level); + this._subscription.unsubscribe(); } } diff --git a/projects/angular/src/utils/breakpoints/breakpoints.ts b/projects/angular/src/utils/breakpoints/breakpoints.ts index 1019db20a3..7ea3a7d6c9 100644 --- a/projects/angular/src/utils/breakpoints/breakpoints.ts +++ b/projects/angular/src/utils/breakpoints/breakpoints.ts @@ -4,6 +4,21 @@ * The full license information can be found in LICENSE in the root directory of this project. */ +/** + * TODO: + * Using core functions like: + * - pluckPixelValue + * - getCssPropertyValue + * + * to get the value of the design token. + * + * Note: Memoization/Cache usage possible. + */ + // iPad mini screen width // http://stephen.io/mediaqueries/#iPadMini export const DATEPICKER_ENABLE_BREAKPOINT = 768; +export const SMALL_BREAKPOINT = 576; +export const MEDIUM_BREAKPOINT = 768; +export const LARGE_BREAKPOINT = 992; +export const EXTRA_LARGE_BREAKPOINT = 1200; diff --git a/projects/angular/src/utils/focus-trap/focus-trap.spec.ts b/projects/angular/src/utils/focus-trap/focus-trap.spec.ts new file mode 100644 index 0000000000..dd58a5d44b --- /dev/null +++ b/projects/angular/src/utils/focus-trap/focus-trap.spec.ts @@ -0,0 +1,313 @@ +/* + * Copyright (c) 2016-2022 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +import { CDS_FOCUS_TRAP_DOCUMENT_ATTR, FocusTrapTrackerService, sleep } from '@cds/core/internal'; +import { + addReboundElementsToFocusTrapElement, + createFocusTrapReboundElement, + elementIsOutsideFocusTrapElement, + FocusTrap, + FocusTrapElement, + refocusIfOutsideFocusTrapElement, + removeReboundElementsFromFocusTrapElement, +} from './focus-trap'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CommonModule } from '@angular/common'; +import { + Component, + ComponentRef, + ElementRef, + Inject, + Injector, + PLATFORM_ID, + Renderer2, + ViewChild, +} from '@angular/core'; + +@Component({ + selector: 'focus-trap', + template: ``, +}) +class FocusTrapComponent extends FocusTrap { + constructor(renderer2: Renderer2, injector: Injector, @Inject(PLATFORM_ID) platformId: any, hostElement: ElementRef) { + super(renderer2, injector, platformId, hostElement.nativeElement); + } +} + +@Component({ + selector: 'test-focus-trap-ng', + template: ` + + Link 1 + `, +}) +class TestFocusTrapComponent { + @ViewChild(FocusTrapComponent) + focusTrapComponent: FocusTrapComponent; +} + +describe('Focus Trap Utilities: ', () => { + let focusedElement: HTMLElement; + let noFocusElement: HTMLElement; + let testElement: FocusTrapElement; + let focusTrapElement: FocusTrapElement; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [CommonModule], + declarations: [FocusTrapComponent, TestFocusTrapComponent], + }); + + fixture = TestBed.createComponent(TestFocusTrapComponent); + fixture.detectChanges(); + + testElement = fixture.elementRef.nativeElement; + focusTrapElement = testElement.querySelector('focus-trap'); + focusedElement = testElement.querySelector('.inside-focus'); + noFocusElement = testElement.querySelector('.outside-focus'); + }); + + afterEach(() => { + fixture.destroy(); + }); + + describe('Functional Helper: ', () => { + describe('addReboundElementsToFocusTrapElement()', () => { + beforeEach(() => { + fixture.componentInstance.focusTrapComponent.enableFocusTrap(); + }); + + it('adds rebound elements correctly when there are no siblings', () => { + addReboundElementsToFocusTrapElement(document, focusTrapElement); + const reboundElements = document.body.querySelectorAll('.offscreen-focus-rebounder'); + expect(reboundElements.length).toBe(2); + expect(reboundElements[0].nextSibling).toEqual(focusTrapElement); + expect(focusTrapElement.nextSibling).toEqual(reboundElements[1]); + }); + + it('does not double-add rebound elements if called twice by accident', () => { + addReboundElementsToFocusTrapElement(document, focusTrapElement); + addReboundElementsToFocusTrapElement(document, focusTrapElement); + const reboundElements = testElement.querySelectorAll('.offscreen-focus-rebounder'); + expect(reboundElements.length).toBe(2); + }); + + it('adds rebound elements correctly when there are siblings ', () => { + // this creates a sibling element to focusTrapElement + const siblingEl = document.createElement('div'); + focusTrapElement.parentElement.appendChild(siblingEl); + + addReboundElementsToFocusTrapElement(document, focusTrapElement); + + const reboundElements = document.querySelectorAll('.offscreen-focus-rebounder'); + expect(reboundElements.length).toBe(2); + + expect(reboundElements[0].nextSibling).toEqual(focusTrapElement); + expect(focusTrapElement.nextSibling).toEqual(reboundElements[1]); + }); + }); + + describe('removeReboundElementsFromFocusTrapElement()', () => { + it('removes rebound elements correctly', () => { + addReboundElementsToFocusTrapElement(document, focusTrapElement); + removeReboundElementsFromFocusTrapElement(focusTrapElement); + expect(document.querySelectorAll('.offscreen-focus-rebounder').length).toBe(0); + }); + + it('does not blow up if no rebound elements', () => { + removeReboundElementsFromFocusTrapElement(focusTrapElement); + expect(removeReboundElementsFromFocusTrapElement).not.toThrow(); + expect(document.querySelectorAll('.offscreen-focus-rebounder').length).toBe(0); + }); + }); + + describe('createFocusTrapReboundElement())', () => { + let testElement: HTMLElement; + + beforeEach(() => { + testElement = createFocusTrapReboundElement(document); + }); + + it('creates a focusable offscreen element', () => { + expect(testElement.getAttribute('tabindex')).toBe('0'); + expect(testElement.classList).toContain('offscreen-focus-rebounder'); + }); + }); + + describe('refocusIfOutsideFocusTrapElement()', () => { + beforeEach(() => { + fixture.componentInstance.focusTrapComponent.enableFocusTrap(); + }); + + afterEach(() => { + fixture.destroy(); + }); + + it('calls focus() if in current focus trap element', () => { + spyOn(focusedElement, 'focus'); + FocusTrapTrackerService.setCurrent(focusTrapElement); + refocusIfOutsideFocusTrapElement(focusedElement, focusTrapElement); + expect(focusedElement.focus).toHaveBeenCalled(); + }); + + it('redirects focus() if focused element not in current focus trap element', () => { + spyOn(noFocusElement, 'focus'); + + refocusIfOutsideFocusTrapElement(noFocusElement, focusTrapElement); + expect(noFocusElement.focus).not.toHaveBeenCalled(); + }); + + it('redirects focus() if it tries to focus a rebounder', async () => { + const topReboundElement = focusTrapElement.topReboundElement; + const bottomReboundElement = focusTrapElement.bottomReboundElement; + spyOn(topReboundElement, 'focus'); + spyOn(bottomReboundElement, 'focus'); + refocusIfOutsideFocusTrapElement(topReboundElement, focusTrapElement); + expect(topReboundElement.focus).not.toHaveBeenCalled(); + refocusIfOutsideFocusTrapElement(bottomReboundElement, focusTrapElement); + expect(bottomReboundElement.focus).not.toHaveBeenCalled(); + }); + }); + + describe('elementIsOutsideFocusTrapElement()', () => { + it('returns true if element is outside focus trap element', async () => { + expect(elementIsOutsideFocusTrapElement(noFocusElement, focusTrapElement)).toBeTruthy(); + }); + + it('returns true if focused element is top rebound element', async () => { + focusTrapElement.topReboundElement = focusedElement; + expect(elementIsOutsideFocusTrapElement(focusedElement, focusTrapElement)).toBeTruthy(); + }); + + it('returns true if focused element is bottom rebound element', async () => { + focusTrapElement.bottomReboundElement = focusedElement; + expect(elementIsOutsideFocusTrapElement(focusedElement, focusTrapElement)).toBeTruthy(); + }); + + it('returns false if element is inside focus trap element', () => { + const focusedElement = document.createElement('div') as HTMLElement; + focusTrapElement.appendChild(focusedElement); + expect(elementIsOutsideFocusTrapElement(focusedElement, focusTrapElement)).toBeFalsy(); + }); + }); + }); +}); + +describe('FocusTrap Class: ', () => { + describe('enableFocusTrap()', () => { + let focusTrap: FocusTrapComponent; + let fixture: ComponentFixture; + let wrapperComponent: ComponentRef; + + beforeEach(async () => { + TestBed.configureTestingModule({ + imports: [CommonModule], + declarations: [TestFocusTrapComponent, FocusTrapComponent], + }); + + fixture = TestBed.createComponent(TestFocusTrapComponent); + wrapperComponent = fixture.componentRef; + fixture.detectChanges(); + focusTrap = wrapperComponent.instance.focusTrapComponent; + focusTrap.enableFocusTrap(); + }); + + afterEach(() => { + fixture.destroy(); + }); + + it('set focus trap id', () => { + expect(focusTrap.focusTrapElement.focusTrapId).toBeTruthy('needs to set focus trap id on plain html elements'); + }); + + it('should add rebound elements', () => { + expect(document.querySelectorAll('.offscreen-focus-rebounder').length).toBe(2); + }); + + it('should have a tabindex of -1 to be able to programatically focus', () => { + expect(focusTrap.focusTrapElement.getAttribute('tabindex')).toBe('-1'); + }); + + it('should not override tabindex if focus trap element already has a tabindex set', () => { + focusTrap.removeFocusTrap(); + focusTrap.focusTrapElement.setAttribute('tabindex', '1'); + focusTrap.enableFocusTrap(); + expect(focusTrap.focusTrapElement.getAttribute('tabindex')).toBe('1'); + expect(focusTrap.focusTrapElement.getAttribute('tabindex')).not.toBe('-1'); + }); + + it('should add to focus trap attr on root element (html) to prevent scrolling', () => { + expect(document.documentElement.getAttribute(CDS_FOCUS_TRAP_DOCUMENT_ATTR)).toBe(''); + }); + + it('should set itself to current on FocusTrapTracker service', () => { + expect(FocusTrapTrackerService.getCurrent()).toEqual(focusTrap.focusTrapElement as any); + }); + + it('should be focused', async () => { + // focus is not immediately set due to safari issue + await sleep(23); + expect(document.activeElement).toEqual(focusTrap.focusTrapElement, 'focus is set asynchronously after creation'); + }); + + it('should be set to active', () => { + expect(focusTrap.active).toBe(true); + }); + + it('should throw an error if enabledFocusTrap is called again', () => { + const secondCall = () => focusTrap.enableFocusTrap(); + expect(secondCall).toThrow(); + }); + }); + + describe('removeFocusTrap()', () => { + let focusTrap: FocusTrapComponent; + let fixture: ComponentFixture; + let wrapperComponent: ComponentRef; + let previousFocusedElement: HTMLElement; + + beforeEach(async () => { + TestBed.configureTestingModule({ + imports: [CommonModule], + declarations: [TestFocusTrapComponent, FocusTrapComponent], + }); + + fixture = TestBed.createComponent(TestFocusTrapComponent); + wrapperComponent = fixture.componentRef; + fixture.detectChanges(); + focusTrap = wrapperComponent.instance.focusTrapComponent; + fixture.nativeElement.querySelector('button').focus(); + previousFocusedElement = document.activeElement as HTMLElement; + }); + + afterEach(() => { + fixture.destroy(); + }); + + it('should remove rebound elements', () => { + focusTrap.removeFocusTrap(); + expect(document.querySelectorAll('.offscreen-focus-rebounder').length).toBe(0); + }); + + it('should remove layout attribute to prevent scrolling on body', () => { + expect(document.body.getAttribute('cds-layout')).toBeNull(); + }); + + it('should not be set as current on FocusTrapTracker', () => { + expect(FocusTrapTrackerService.getCurrent()).not.toEqual(previousFocusedElement as any); + }); + + it('should not be set to active', () => { + expect(focusTrap.active).toBe(false); + }); + + it('should restore previous focus', () => { + focusTrap.removeFocusTrap(); + expect(document.activeElement).toEqual(previousFocusedElement); + }); + }); +}); diff --git a/projects/angular/src/utils/focus-trap/focus-trap.ts b/projects/angular/src/utils/focus-trap/focus-trap.ts new file mode 100644 index 0000000000..caaa9f10e8 --- /dev/null +++ b/projects/angular/src/utils/focus-trap/focus-trap.ts @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2016-2022 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +import { createId, FocusTrapTrackerService, isFocusable, isHTMLElement } from '@cds/core/internal'; +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; +import { Injectable, Injector, Renderer2 } from '@angular/core'; + +export interface FocusTrapElement extends HTMLElement { + topReboundElement: HTMLElement | undefined; + bottomReboundElement: HTMLElement | undefined; + focusTrapId: string; +} + +export function refocusIfOutsideFocusTrapElement( + focusedElement: HTMLElement, + focusTrapElement: FocusTrapElement, + elementToRefocus?: HTMLElement +) { + const focusTrapIsCurrent = FocusTrapTrackerService.getCurrent() === focusTrapElement; + const elementToFocusIsOutsideFocusTrap = elementIsOutsideFocusTrapElement(focusedElement, focusTrapElement); + + if (focusTrapIsCurrent && elementToFocusIsOutsideFocusTrap) { + elementToRefocus = elementToRefocus || focusTrapElement; + elementToRefocus.focus(); + } else { + focusedElement.focus(); + } +} + +export function elementIsOutsideFocusTrapElement( + focusedElement: HTMLElement, + focusTrapElement: FocusTrapElement +): boolean { + if ( + focusedElement === focusTrapElement.topReboundElement || + focusedElement === focusTrapElement.bottomReboundElement + ) { + return true; + } + + const elementIsInFocusTrapLightDom = focusTrapElement.contains(focusedElement); + + if (elementIsInFocusTrapLightDom) { + return false; + } + + if (focusTrapElement !== null && focusTrapElement.contains(focusedElement)) { + return false; + } + + return true; +} + +export function createFocusTrapReboundElement(document: Document) { + const offScreenSpan = document.createElement('span'); + offScreenSpan.setAttribute('tabindex', '0'); + offScreenSpan.classList.add('offscreen-focus-rebounder'); + return offScreenSpan; +} + +export function addReboundElementsToFocusTrapElement(document: Document, focusTrapElement: FocusTrapElement) { + if (focusTrapElement && !focusTrapElement.topReboundElement && !focusTrapElement.bottomReboundElement) { + focusTrapElement.topReboundElement = createFocusTrapReboundElement(document); + focusTrapElement.bottomReboundElement = createFocusTrapReboundElement(document); + + const parent = focusTrapElement.parentElement; + const sibling = focusTrapElement.nextSibling; + + if (parent) { + parent.insertBefore(focusTrapElement.topReboundElement, focusTrapElement); + if (sibling) { + parent.insertBefore(focusTrapElement.bottomReboundElement, sibling); + } else { + parent.appendChild(focusTrapElement.bottomReboundElement); + } + } + } +} + +export function removeReboundElementsFromFocusTrapElement(focusTrapElement: FocusTrapElement) { + if (focusTrapElement) { + const parent = focusTrapElement.parentElement; + + if (parent) { + const topRebound = focusTrapElement.topReboundElement; + const bottomRebound = focusTrapElement.bottomReboundElement; + if (topRebound) { + parent.removeChild(topRebound); + } + if (bottomRebound) { + parent.removeChild(bottomRebound); + } + } + // These are here to to make sure that we completely delete all traces of the removed DOM objects. + delete focusTrapElement.topReboundElement; + delete focusTrapElement.bottomReboundElement; + } +} + +// this helper exists to enable the focus trap class to handle vanilla html elements +// it's primary concern is to keep TS happy. +export function castHtmlElementToFocusTrapElement(el: HTMLElement): FocusTrapElement { + return el as FocusTrapElement; +} + +@Injectable() +export class FocusTrap { + focusTrapElement: FocusTrapElement; + private previousFocus: HTMLElement; + private onFocusInEvent: () => void; + private unlisten: () => void; + + protected _document: Document; + + firstFocusElement: HTMLElement | FocusTrapElement; + + active = false; + + constructor(protected renderer: Renderer2, injector: Injector, platformId: any, hostElement: FocusTrapElement) { + if (isPlatformBrowser(platformId)) { + this._document = injector.get(DOCUMENT); + } + + hostElement = castHtmlElementToFocusTrapElement(hostElement); + + if (!hostElement.focusTrapId) { + hostElement.focusTrapId = createId(); + } + + this.focusTrapElement = hostElement; + } + + enableFocusTrap() { + const fte = this.focusTrapElement; + const firstFocusElement = fte.querySelector('[cds-first-focus]'); + const activeEl = this._document.activeElement; + + if (FocusTrapTrackerService.getCurrent() === fte) { + throw new Error('Focus trap is already enabled for this instance.'); + } + + this.firstFocusElement = (firstFocusElement as HTMLElement) || this.focusTrapElement; + + addReboundElementsToFocusTrapElement(this._document, fte); + + if (!isFocusable(fte)) { + fte.setAttribute('tabindex', '-1'); + } + + if (activeEl && isHTMLElement(activeEl)) { + this.previousFocus = activeEl as HTMLElement; + } + + FocusTrapTrackerService.setCurrent(fte); + + // setTimeout here is required for Safari which may try to set focus on + // element before it is visible... + const focusTimer = setTimeout(() => { + this.firstFocusElement.focus(); + clearTimeout(focusTimer); + }, 10); + + this.onFocusInEvent = this.onFocusIn.bind(this); + this.unlisten = this.renderer.listen(this._document, 'focusin', this.onFocusInEvent); + this.active = true; + } + + removeFocusTrap() { + if (this.unlisten) { + this.unlisten(); + } + + removeReboundElementsFromFocusTrapElement(this.focusTrapElement); + this.renderer.removeAttribute(this.focusTrapElement, 'tabindex'); + FocusTrapTrackerService.activatePreviousCurrent(); + this.active = false; + if (this.previousFocus) { + this.previousFocus.focus(); + } + } + + private onFocusIn(event: FocusEvent) { + refocusIfOutsideFocusTrapElement( + event.composedPath()[0] as HTMLElement, + this.focusTrapElement, + this.firstFocusElement + ); + } +}