From a691c0401893544d37286f98703296fae111c363 Mon Sep 17 00:00:00 2001 From: xidedix Date: Wed, 7 Oct 2026 15:58:11 +0200 Subject: [PATCH 1/7] fix(Offcanvas): closed offcanvas laid out in place stays interactive role and aria-modal only while open; inert only for a closed panel (computed position fixed), re-read after render and on resize; hiding class only for a real hide; focus trap enabled only while open, no auto-capture. --- .../offcanvas/offcanvas.component.html | 3 +- .../offcanvas/offcanvas.component.spec.ts | 66 +++++++++++++++++++ .../offcanvas/offcanvas.component.ts | 29 ++++++-- 3 files changed, 89 insertions(+), 9 deletions(-) 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..a7dc0a31 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,6 +1,8 @@ /// +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'; @@ -21,6 +23,7 @@ describe('OffcanvasComponent', () => { component = fixture.componentInstance; componentRef = fixture.componentRef; document = TestBed.inject(DOCUMENT); + fixture.nativeElement.style.position = 'fixed'; fixture.detectChanges(); vi.useFakeTimers(); @@ -109,6 +112,69 @@ 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 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); + }); + 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..860fa424 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 */ @@ -134,6 +137,7 @@ export class OffcanvasComponent implements OnInit, OnDestroy { #layoutChangeSubscription!: Subscription; #hideFallbackId?: ReturnType; #isShown = false; + readonly #inPlace = signal(false); /** * Allow body scrolling while offcanvas is visible. @@ -180,6 +184,8 @@ export class OffcanvasComponent implements OnInit, OnDestroy { }); }); + readonly #afterEveryRenderFn = afterEveryRender({ read: () => this.#readPosition() }); + readonly visibleEffect = effect(() => { const visible = this.visible(); this.animateStart(visible); @@ -212,7 +218,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 +258,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 +271,10 @@ export class OffcanvasComponent implements OnInit, OnDestroy { } } + onResizeHandler(): void { + this.#readPosition(); + } + ngOnInit(): void { this.stateToggleSubscribe(); setTimeout(() => { @@ -321,6 +331,11 @@ 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'); + } + setFocus(): void { if (isPlatformBrowser(this.#platformId)) { setTimeout(() => this.#hostElement.nativeElement.focus()); From c0503f3ab19a54f1e87a7ad363b8a2558498ccf0 Mon Sep 17 00:00:00 2001 From: xidedix Date: Wed, 7 Oct 2026 22:34:01 +0200 Subject: [PATCH 2/7] test(Offcanvas): focus trap is disabled again after close --- .../src/lib/offcanvas/offcanvas/offcanvas.component.spec.ts | 3 +++ 1 file changed, 3 insertions(+) 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 a7dc0a31..8725cd40 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 @@ -173,6 +173,9 @@ describe('OffcanvasComponent', () => { componentRef.setInput('visible', true); fixture.detectChanges(); expect(trap.enabled).toBe(true); + componentRef.setInput('visible', false); + fixture.detectChanges(); + expect(trap.enabled).toBe(false); }); describe('with portal', () => { From 9bd96e19cd865d84c988974da4dca3ee1f813d45 Mon Sep 17 00:00:00 2001 From: xidedix Date: Wed, 7 Oct 2026 23:04:05 +0200 Subject: [PATCH 3/7] fix(Offcanvas): return focus on close, to the toggle that opened it first On close or destroy while open, focus goes to the cOffcanvasToggle that opened the offcanvas, else to the element focused when it opened; only when focus is inside the offcanvas or on body, and skipping a target that is detached or cannot take focus. --- .../offcanvas-toggle.directive.spec.ts | 12 +-- .../offcanvas-toggle.directive.ts | 5 +- .../src/lib/offcanvas/offcanvas.service.ts | 1 + .../offcanvas/offcanvas.component.spec.ts | 76 +++++++++++++++++++ .../offcanvas/offcanvas.component.ts | 36 ++++++++- 5 files changed, 122 insertions(+), 8 deletions(-) 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.spec.ts b/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.spec.ts index 8725cd40..940297aa 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 @@ -5,6 +5,7 @@ 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; @@ -178,6 +179,81 @@ describe('OffcanvasComponent', () => { 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 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 860fa424..f6ce826e 100644 --- a/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.ts +++ b/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.ts @@ -136,7 +136,9 @@ export class OffcanvasComponent implements OnInit, OnDestroy { #backdropClickSubscription!: Subscription; #layoutChangeSubscription!: Subscription; #hideFallbackId?: ReturnType; + #focusBeforeShow: HTMLElement | null = null; #isShown = false; + #trigger: HTMLElement | null = null; readonly #inPlace = signal(false); /** @@ -190,9 +192,11 @@ export class OffcanvasComponent implements OnInit, OnDestroy { const visible = this.visible(); this.animateStart(visible); if (visible) { + this.#focusBeforeShow = this.#document.activeElement as HTMLElement | null; this.setBackdrop(this.backdrop()); this.setFocus(); } else { + this.#restoreFocus(); this.setBackdrop(false); } this.layoutChangeSubscribe(visible); @@ -286,6 +290,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(); @@ -342,11 +349,38 @@ export class OffcanvasComponent implements OnInit, OnDestroy { } } + #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); } } }); From 833c4b3ecaedbd843eaef038416f595df11d809d Mon Sep 17 00:00:00 2001 From: xidedix Date: Wed, 7 Oct 2026 23:13:19 +0200 Subject: [PATCH 4/7] fix(Offcanvas): remember the focused element only when the offcanvas opens --- .../offcanvas/offcanvas.component.spec.ts | 33 +++++++++++++++++++ .../offcanvas/offcanvas.component.ts | 5 ++- 2 files changed, 37 insertions(+), 1 deletion(-) 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 940297aa..292070dd 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 @@ -223,6 +223,39 @@ describe('OffcanvasComponent', () => { 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); 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 f6ce826e..2e3acd54 100644 --- a/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.ts +++ b/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.ts @@ -190,9 +190,12 @@ export class OffcanvasComponent implements OnInit, OnDestroy { readonly visibleEffect = effect(() => { const visible = this.visible(); + const wasShown = this.#isShown; this.animateStart(visible); if (visible) { - this.#focusBeforeShow = this.#document.activeElement as HTMLElement | null; + if (!wasShown) { + this.#focusBeforeShow = this.#document.activeElement as HTMLElement | null; + } this.setBackdrop(this.backdrop()); this.setFocus(); } else { From 211d633d2dfa5dbc1d828358caab7a5fe77bff2c Mon Sep 17 00:00:00 2001 From: xidedix Date: Wed, 7 Oct 2026 23:32:33 +0200 Subject: [PATCH 5/7] fix(Offcanvas): close an open offcanvas that a window resize lays out in place --- .../offcanvas/offcanvas/offcanvas.component.spec.ts | 13 +++++++++++++ .../lib/offcanvas/offcanvas/offcanvas.component.ts | 3 +++ 2 files changed, 16 insertions(+) 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 292070dd..af3142ab 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 @@ -158,6 +158,19 @@ describe('OffcanvasComponent', () => { 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(); + 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(); + }); + it('should re-evaluate the panel position when responsive changes', async () => { fixture.nativeElement.style.position = 'static'; componentRef.setInput('responsive', 'lg'); 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 2e3acd54..0b7a6d53 100644 --- a/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.ts +++ b/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.ts @@ -280,6 +280,9 @@ export class OffcanvasComponent implements OnInit, OnDestroy { onResizeHandler(): void { this.#readPosition(); + if (this.visible() && this.#inPlace()) { + this.#offcanvasService.toggle({ show: false, id: this.id() }); + } } ngOnInit(): void { From b95b8e893c7459a4fadde02b690b7792b2216965 Mon Sep 17 00:00:00 2001 From: xidedix Date: Wed, 7 Oct 2026 23:34:42 +0200 Subject: [PATCH 6/7] test(Offcanvas): resize close also clears the backdrop --- .../src/lib/offcanvas/offcanvas/offcanvas.component.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) 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 af3142ab..f662cbb4 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 @@ -161,6 +161,8 @@ describe('OffcanvasComponent', () => { 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); @@ -169,6 +171,8 @@ describe('OffcanvasComponent', () => { 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 re-evaluate the panel position when responsive changes', async () => { From 4b2927e4371234ef0439340979b042e6b08d1171 Mon Sep 17 00:00:00 2001 From: xidedix Date: Wed, 7 Oct 2026 23:45:58 +0200 Subject: [PATCH 7/7] fix(Offcanvas): resize closes only an offcanvas that was shown as a panel --- .../offcanvas/offcanvas.component.spec.ts | 24 +++++++++++++++++++ .../offcanvas/offcanvas.component.ts | 7 +++++- 2 files changed, 30 insertions(+), 1 deletion(-) 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 f662cbb4..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 @@ -175,6 +175,30 @@ describe('OffcanvasComponent', () => { 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'); 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 0b7a6d53..7fd23e60 100644 --- a/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.ts +++ b/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.ts @@ -138,6 +138,7 @@ export class OffcanvasComponent implements OnInit, OnDestroy { #hideFallbackId?: ReturnType; #focusBeforeShow: HTMLElement | null = null; #isShown = false; + #shownAsPanel = false; #trigger: HTMLElement | null = null; readonly #inPlace = signal(false); @@ -199,6 +200,7 @@ export class OffcanvasComponent implements OnInit, OnDestroy { this.setBackdrop(this.backdrop()); this.setFocus(); } else { + this.#shownAsPanel = false; this.#restoreFocus(); this.setBackdrop(false); } @@ -280,7 +282,7 @@ export class OffcanvasComponent implements OnInit, OnDestroy { onResizeHandler(): void { this.#readPosition(); - if (this.visible() && this.#inPlace()) { + if (this.visible() && this.#shownAsPanel && this.#inPlace()) { this.#offcanvasService.toggle({ show: false, id: this.id() }); } } @@ -347,6 +349,9 @@ export class OffcanvasComponent implements OnInit, OnDestroy { #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 {