diff --git a/projects/coreui-angular/src/lib/offcanvas/offcanvas-toggle/offcanvas-toggle.directive.spec.ts b/projects/coreui-angular/src/lib/offcanvas/offcanvas-toggle/offcanvas-toggle.directive.spec.ts index dc7cb30f..5f80e361 100644 --- a/projects/coreui-angular/src/lib/offcanvas/offcanvas-toggle/offcanvas-toggle.directive.spec.ts +++ b/projects/coreui-angular/src/lib/offcanvas/offcanvas-toggle/offcanvas-toggle.directive.spec.ts @@ -1,4 +1,4 @@ -import { Component, DebugElement } from '@angular/core'; +import { Component, DebugElement, ElementRef } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { take } from 'rxjs/operators'; @@ -6,6 +6,8 @@ import { take } from 'rxjs/operators'; import { OffcanvasToggleDirective } from './offcanvas-toggle.directive'; import { OffcanvasService } from '../offcanvas.service'; +class MockElementRef extends ElementRef {} + @Component({ template: ` `, imports: [OffcanvasToggleDirective] @@ -21,7 +23,7 @@ describe('OffcanvasToggleDirective', () => { beforeEach(() => { TestBed.configureTestingModule({ imports: [OffcanvasToggleDirective, TestComponent], - providers: [OffcanvasService] + providers: [OffcanvasService, { provide: ElementRef, useClass: MockElementRef }] }); fixture = TestBed.createComponent(TestComponent); component = fixture.componentInstance; @@ -38,9 +40,9 @@ describe('OffcanvasToggleDirective', () => { }); it('should toggle offcanvas on click', async () => { - service.offcanvasState$.pipe(take(1)).subscribe((value) => { - expect(value).toEqual({ show: 'toggle', id: 'OffcanvasEnd' }); - }); + let action; + service.offcanvasState$.pipe(take(1)).subscribe((value) => (action = value)); debugElement.nativeElement.dispatchEvent(new MouseEvent('click')); + expect(action).toEqual({ show: 'toggle', id: 'OffcanvasEnd', trigger: debugElement.nativeElement }); }); }); diff --git a/projects/coreui-angular/src/lib/offcanvas/offcanvas-toggle/offcanvas-toggle.directive.ts b/projects/coreui-angular/src/lib/offcanvas/offcanvas-toggle/offcanvas-toggle.directive.ts index d54703ee..6e2e4abf 100644 --- a/projects/coreui-angular/src/lib/offcanvas/offcanvas-toggle/offcanvas-toggle.directive.ts +++ b/projects/coreui-angular/src/lib/offcanvas/offcanvas-toggle/offcanvas-toggle.directive.ts @@ -1,4 +1,4 @@ -import { Directive, inject, input } from '@angular/core'; +import { Directive, ElementRef, inject, input } from '@angular/core'; import { OffcanvasService } from '../offcanvas.service'; @@ -9,6 +9,7 @@ import { OffcanvasService } from '../offcanvas.service'; } }) export class OffcanvasToggleDirective { + readonly #elementRef = inject(ElementRef); readonly #offcanvasService = inject(OffcanvasService); /** @@ -19,6 +20,6 @@ export class OffcanvasToggleDirective { protected toggleOpen($event: MouseEvent): void { $event.preventDefault(); - this.#offcanvasService.toggle({ show: 'toggle', id: this.id() }); + this.#offcanvasService.toggle({ show: 'toggle', id: this.id(), trigger: this.#elementRef.nativeElement }); } } diff --git a/projects/coreui-angular/src/lib/offcanvas/offcanvas.service.ts b/projects/coreui-angular/src/lib/offcanvas/offcanvas.service.ts index ec6a0b6c..3391c680 100644 --- a/projects/coreui-angular/src/lib/offcanvas/offcanvas.service.ts +++ b/projects/coreui-angular/src/lib/offcanvas/offcanvas.service.ts @@ -6,6 +6,7 @@ export interface IOffcanvasAction { show?: boolean | 'toggle'; offcanvas?: OffcanvasComponent; id?: string; + trigger?: HTMLElement; } @Injectable({ diff --git a/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.html b/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.html index 58481089..1346ee12 100644 --- a/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.html +++ b/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.html @@ -1,4 +1,3 @@ -
+
- diff --git a/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.spec.ts b/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.spec.ts index 0e444132..95ab94e8 100644 --- a/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.spec.ts +++ b/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.spec.ts @@ -1,8 +1,11 @@ /// +import { CdkTrapFocus } from '@angular/cdk/a11y'; import { ComponentRef, DOCUMENT } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { OffcanvasComponent } from './offcanvas.component'; +import { OffcanvasService } from '../offcanvas.service'; describe('OffcanvasComponent', () => { let component: OffcanvasComponent; @@ -21,6 +24,7 @@ describe('OffcanvasComponent', () => { component = fixture.componentInstance; componentRef = fixture.componentRef; document = TestBed.inject(DOCUMENT); + fixture.nativeElement.style.position = 'fixed'; fixture.detectChanges(); vi.useFakeTimers(); @@ -109,6 +113,221 @@ describe('OffcanvasComponent', () => { expect(fixture.componentInstance.responsiveBreakpoint).toBe(false); }); + it('should not carry the hiding class before it was ever shown', async () => { + await vi.runAllTimersAsync(); + expect(fixture.nativeElement.classList.contains('hiding')).toBe(false); + expect(fixture.nativeElement.classList.contains('showing')).toBe(false); + }); + + it('should add the hiding class only for a real hide transition', async () => { + componentRef.setInput('visible', true); + fixture.detectChanges(); + await vi.runAllTimersAsync(); + componentRef.setInput('visible', false); + fixture.detectChanges(); + expect(fixture.nativeElement.classList.contains('hiding')).toBe(true); + await vi.runAllTimersAsync(); + expect(fixture.nativeElement.classList.contains('hiding')).toBe(false); + }); + + it('should render role and aria-modal only while open', async () => { + expect(fixture.nativeElement.getAttribute('role')).toBeNull(); + expect(fixture.nativeElement.getAttribute('aria-modal')).toBeNull(); + componentRef.setInput('visible', true); + fixture.detectChanges(); + expect(fixture.nativeElement.getAttribute('role')).toBe('dialog'); + expect(fixture.nativeElement.getAttribute('aria-modal')).toBe('true'); + componentRef.setInput('visible', false); + fixture.detectChanges(); + expect(fixture.nativeElement.getAttribute('role')).toBeNull(); + expect(fixture.nativeElement.getAttribute('aria-modal')).toBeNull(); + }); + + it('should set inert only when the closed offcanvas is positioned as a panel', async () => { + expect(fixture.nativeElement.inert).toBe(true); + fixture.nativeElement.style.position = 'static'; + fixture.detectChanges(); + fixture.detectChanges(); + expect(fixture.nativeElement.inert).toBeFalsy(); + }); + + it('should re-evaluate the panel position on window resize', async () => { + expect(componentRef.instance.ariaHidden()).toBe(true); + fixture.nativeElement.style.position = 'static'; + window.dispatchEvent(new Event('resize')); + expect(componentRef.instance.ariaHidden()).toBeNull(); + }); + + it('should close when a window resize lays the open offcanvas out in place', async () => { + componentRef.setInput('visible', true); + fixture.detectChanges(); + await vi.runAllTimersAsync(); + expect(document.querySelector('.offcanvas-backdrop')).not.toBeNull(); + window.dispatchEvent(new Event('resize')); + fixture.detectChanges(); + expect(componentRef.instance.visible()).toBe(true); + fixture.nativeElement.style.position = 'static'; + window.dispatchEvent(new Event('resize')); + fixture.detectChanges(); + expect(componentRef.instance.visible()).toBe(false); + expect(fixture.nativeElement.getAttribute('role')).toBeNull(); + await vi.runAllTimersAsync(); + expect(document.querySelector('.offcanvas-backdrop')).toBeNull(); + }); + + it('should stay open on resize when it was never positioned as a panel', async () => { + fixture.nativeElement.style.position = 'absolute'; + componentRef.setInput('visible', true); + fixture.detectChanges(); + window.dispatchEvent(new Event('resize')); + fixture.detectChanges(); + expect(componentRef.instance.visible()).toBe(true); + }); + + it('should not close on resize after reopening in place', async () => { + componentRef.setInput('visible', true); + fixture.detectChanges(); + window.dispatchEvent(new Event('resize')); + fixture.detectChanges(); + componentRef.setInput('visible', false); + fixture.detectChanges(); + fixture.nativeElement.style.position = 'static'; + componentRef.setInput('visible', true); + fixture.detectChanges(); + window.dispatchEvent(new Event('resize')); + fixture.detectChanges(); + expect(componentRef.instance.visible()).toBe(true); + }); + + it('should re-evaluate the panel position when responsive changes', async () => { + fixture.nativeElement.style.position = 'static'; + componentRef.setInput('responsive', 'lg'); + fixture.detectChanges(); + fixture.detectChanges(); + expect(fixture.nativeElement.classList.contains('offcanvas-lg')).toBe(true); + expect(fixture.nativeElement.inert).toBeFalsy(); + }); + + it('should enable the focus trap only while open', async () => { + const trap = fixture.debugElement.query(By.directive(CdkTrapFocus)).injector.get(CdkTrapFocus); + expect(trap.enabled).toBe(false); + expect(trap.autoCapture).toBe(false); + componentRef.setInput('visible', true); + fixture.detectChanges(); + expect(trap.enabled).toBe(true); + componentRef.setInput('visible', false); + fixture.detectChanges(); + expect(trap.enabled).toBe(false); + }); + + describe('focus return', () => { + let input: HTMLInputElement; + let service: OffcanvasService; + let trigger: HTMLButtonElement; + + const toggle = async (show: boolean | 'toggle', toggleTrigger?: HTMLElement) => { + service.toggle({ show, id: component.id(), trigger: toggleTrigger }); + fixture.detectChanges(); + await vi.runAllTimersAsync(); + }; + + beforeEach(() => { + service = TestBed.inject(OffcanvasService); + input = document.createElement('input'); + trigger = document.createElement('button'); + document.body.append(input, trigger); + }); + + afterEach(() => { + input.remove(); + trigger.remove(); + }); + + it('should return focus to the toggle that opened it', async () => { + const closeButton = document.createElement('button'); + fixture.nativeElement.append(closeButton); + input.focus(); + await toggle('toggle', trigger); + closeButton.focus(); + await toggle('toggle', closeButton); + expect(document.activeElement).toBe(trigger); + }); + + it('should return focus to the element focused before opening without a toggle', async () => { + input.focus(); + componentRef.setInput('visible', true); + fixture.detectChanges(); + await vi.runAllTimersAsync(); + expect(document.activeElement).toBe(fixture.nativeElement); + componentRef.setInput('visible', false); + fixture.detectChanges(); + expect(document.activeElement).toBe(input); + }); + + it('should keep the element focused before opening when an input changes while open', async () => { + input.focus(); + componentRef.setInput('visible', true); + fixture.detectChanges(); + await vi.runAllTimersAsync(); + componentRef.setInput('backdrop', 'static'); + fixture.detectChanges(); + await vi.runAllTimersAsync(); + componentRef.setInput('visible', false); + fixture.detectChanges(); + expect(document.activeElement).toBe(input); + }); + + it('should not reuse the toggle of an earlier opening', async () => { + await toggle(true, trigger); + await toggle(false); + input.focus(); + componentRef.setInput('visible', true); + fixture.detectChanges(); + await vi.runAllTimersAsync(); + componentRef.setInput('visible', false); + fixture.detectChanges(); + expect(document.activeElement).toBe(input); + }); + + it('should return focus that fell to the body', async () => { + await toggle(true, trigger); + (document.activeElement as HTMLElement).blur(); + expect(document.activeElement).toBe(document.body); + await toggle(false); + expect(document.activeElement).toBe(trigger); + }); + + it('should fall back when the toggle cannot take focus', async () => { + input.focus(); + await toggle(true, trigger); + trigger.disabled = true; + await toggle(false); + expect(document.activeElement).toBe(input); + }); + + it('should return focus when destroyed while open', async () => { + await toggle(true, trigger); + expect(document.activeElement).toBe(fixture.nativeElement); + fixture.destroy(); + expect(document.activeElement).toBe(trigger); + }); + + it('should not move focus that left the panel', async () => { + await toggle(true, trigger); + input.focus(); + await toggle(false); + expect(document.activeElement).toBe(input); + }); + + it('should not focus a detached element', async () => { + await toggle(true, trigger); + trigger.remove(); + const focus = vi.spyOn(trigger, 'focus'); + await toggle(false); + expect(focus).not.toHaveBeenCalled(); + }); + }); + describe('with portal', () => { let container: HTMLDivElement; let originalParent: HTMLElement; diff --git a/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.ts b/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.ts index a7166ee8..7fd23e60 100644 --- a/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.ts +++ b/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.ts @@ -4,6 +4,7 @@ import { BreakpointObserver, BreakpointState } from '@angular/cdk/layout'; import { DomPortal, DomPortalOutlet } from '@angular/cdk/portal'; import { isPlatformBrowser } from '@angular/common'; import { + afterEveryRender, booleanAttribute, Component, computed, @@ -19,6 +20,7 @@ import { output, PLATFORM_ID, Renderer2, + signal, untracked } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -41,11 +43,12 @@ let nextId = 0; host: { '[attr.id]': 'id()', '[inert]': 'ariaHidden()', - '[attr.role]': 'role()', - '[aria-modal]': 'ariaModal()', + '[attr.role]': 'visible() ? role() : null', + '[aria-modal]': 'visible() ? ariaModal() : null', '[attr.tabindex]': 'tabIndex', '[class]': 'hostClasses()', - '(document:keydown)': 'onKeyDownHandler($event)' + '(document:keydown)': 'onKeyDownHandler($event)', + '(window:resize)': 'onResizeHandler()' } }) export class OffcanvasComponent implements OnInit, OnDestroy { @@ -116,14 +119,14 @@ export class OffcanvasComponent implements OnInit, OnDestroy { readonly id = input(`offcanvas-${this.placement()}-${nextId++}`); /** - * Default role for offcanvas + * Role attribute rendered while the offcanvas is open * @returns string * @default 'dialog' */ readonly role = input('dialog'); /** - * Set aria-modal html attr for offcanvas + * aria-modal attribute rendered while the offcanvas is open * @returns boolean * @default true */ @@ -133,7 +136,11 @@ export class OffcanvasComponent implements OnInit, OnDestroy { #backdropClickSubscription!: Subscription; #layoutChangeSubscription!: Subscription; #hideFallbackId?: ReturnType; + #focusBeforeShow: HTMLElement | null = null; #isShown = false; + #shownAsPanel = false; + #trigger: HTMLElement | null = null; + readonly #inPlace = signal(false); /** * Allow body scrolling while offcanvas is visible. @@ -180,13 +187,21 @@ export class OffcanvasComponent implements OnInit, OnDestroy { }); }); + readonly #afterEveryRenderFn = afterEveryRender({ read: () => this.#readPosition() }); + readonly visibleEffect = effect(() => { const visible = this.visible(); + const wasShown = this.#isShown; this.animateStart(visible); if (visible) { + if (!wasShown) { + this.#focusBeforeShow = this.#document.activeElement as HTMLElement | null; + } this.setBackdrop(this.backdrop()); this.setFocus(); } else { + this.#shownAsPanel = false; + this.#restoreFocus(); this.setBackdrop(false); } this.layoutChangeSubscribe(visible); @@ -212,7 +227,7 @@ export class OffcanvasComponent implements OnInit, OnDestroy { }); readonly ariaHidden = computed(() => { - return this.visible() ? null : true; + return this.visible() || this.#inPlace() ? null : true; }); get tabIndex(): string | null { @@ -252,8 +267,8 @@ export class OffcanvasComponent implements OnInit, OnDestroy { this.#renderer.addClass(this.#hostElement.nativeElement, 'showing'); } else { this.#renderer.removeClass(this.#hostElement.nativeElement, 'showing'); - this.#renderer.addClass(this.#hostElement.nativeElement, 'hiding'); if (wasShown) { + this.#renderer.addClass(this.#hostElement.nativeElement, 'hiding'); this.#scheduleHideFallback(); } } @@ -265,6 +280,13 @@ export class OffcanvasComponent implements OnInit, OnDestroy { } } + onResizeHandler(): void { + this.#readPosition(); + if (this.visible() && this.#shownAsPanel && this.#inPlace()) { + this.#offcanvasService.toggle({ show: false, id: this.id() }); + } + } + ngOnInit(): void { this.stateToggleSubscribe(); setTimeout(() => { @@ -276,6 +298,9 @@ export class OffcanvasComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { + if (this.visible()) { + this.#restoreFocus(); + } this.#offcanvasService.toggle({ show: false, id: this.id() }); this.#removeEventListeners(); this.#clearHideFallback(); @@ -321,17 +346,52 @@ export class OffcanvasComponent implements OnInit, OnDestroy { this.#hideFallbackId = undefined; } + #readPosition(): void { + const position = this.#document.defaultView?.getComputedStyle(this.#hostElement.nativeElement).position; + this.#inPlace.set(!!position && position !== 'fixed'); + if (position === 'fixed' && this.visible()) { + this.#shownAsPanel = true; + } + } + setFocus(): void { if (isPlatformBrowser(this.#platformId)) { setTimeout(() => this.#hostElement.nativeElement.focus()); } } + #restoreFocus(): void { + const candidates = [this.#trigger, this.#focusBeforeShow]; + this.#trigger = null; + this.#focusBeforeShow = null; + if (!isPlatformBrowser(this.#platformId)) { + return; + } + const host = this.#hostElement.nativeElement; + const active = this.#document.activeElement; + if (active && active !== this.#document.body && !host.contains(active)) { + return; + } + for (const target of candidates) { + if (target?.isConnected && target !== this.#document.body) { + target.focus({ preventScroll: true }); + if (this.#document.activeElement === target) { + return; + } + } + } + } + private stateToggleSubscribe(): void { this.#offcanvasService.offcanvasState$.pipe(takeUntilDestroyed(this.#destroyRef)).subscribe((action) => { if (this === action.offcanvas || this.id() === action.id) { if ('show' in action) { - this.visible.update((value) => (action?.show === 'toggle' ? !value : action.show)); + const visible = this.visible(); + const show = action?.show === 'toggle' ? !visible : !!action.show; + if (show && !visible) { + this.#trigger = action.trigger ?? null; + } + this.visible.set(show); } } });