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