From bf84b197155426a95064eb9b7796543ba863135f Mon Sep 17 00:00:00 2001 From: xidedix Date: Thu, 8 Oct 2026 01:17:29 +0200 Subject: [PATCH 1/7] fix(Modal): return focus on close to the toggle that opened it first cModalToggle passes its host as the trigger; on close or destroy while open, focus goes to that toggle, else to the element focused at open, only while focus is inside the modal or on body. The focus trap no longer auto-captures. Offcanvas and Modal share restoreFocus from shared/focus.utils. --- .../modal-toggle.directive.spec.ts | 14 ++- .../modal-dismiss/modal-toggle.directive.ts | 5 +- .../src/lib/modal/modal.service.ts | 1 + .../src/lib/modal/modal/modal.component.html | 2 +- .../lib/modal/modal/modal.component.spec.ts | 93 +++++++++++++++++++ .../src/lib/modal/modal/modal.component.ts | 38 +++++--- .../offcanvas/offcanvas.component.ts | 18 +--- .../src/lib/shared/focus.utils.spec.ts | 79 ++++++++++++++++ .../src/lib/shared/focus.utils.ts | 23 +++++ 9 files changed, 241 insertions(+), 32 deletions(-) create mode 100644 projects/coreui-angular/src/lib/shared/focus.utils.spec.ts create mode 100644 projects/coreui-angular/src/lib/shared/focus.utils.ts diff --git a/projects/coreui-angular/src/lib/modal/modal-dismiss/modal-toggle.directive.spec.ts b/projects/coreui-angular/src/lib/modal/modal-dismiss/modal-toggle.directive.spec.ts index d453a086..e1eba26d 100644 --- a/projects/coreui-angular/src/lib/modal/modal-dismiss/modal-toggle.directive.spec.ts +++ b/projects/coreui-angular/src/lib/modal/modal-dismiss/modal-toggle.directive.spec.ts @@ -1,9 +1,12 @@ /// import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Component, DebugElement } from '@angular/core'; +import { Component, DebugElement, ElementRef } from '@angular/core'; import { By } from '@angular/platform-browser'; import { ModalToggleDirective } from './modal-toggle.directive'; import { vi } from 'vitest'; +import { ModalService } from '../modal.service'; + +class MockElementRef extends ElementRef {} @Component({ template: '', @@ -18,7 +21,8 @@ describe('ModalDismissDirective', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [TestComponent] + imports: [TestComponent], + providers: [{ provide: ElementRef, useClass: MockElementRef }] }).compileComponents(); fixture = TestBed.createComponent(TestComponent); @@ -43,4 +47,10 @@ describe('ModalDismissDirective', () => { expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledWith(expect.any(MouseEvent)); }); + + it('should pass its host as the trigger', () => { + const toggle = vi.spyOn(TestBed.inject(ModalService), 'toggle'); + debugElement.nativeElement.click(); + expect(toggle).toHaveBeenCalledWith({ show: 'toggle', id: '', trigger: debugElement.nativeElement }); + }); }); diff --git a/projects/coreui-angular/src/lib/modal/modal-dismiss/modal-toggle.directive.ts b/projects/coreui-angular/src/lib/modal/modal-dismiss/modal-toggle.directive.ts index 308eb555..a9a4d677 100644 --- a/projects/coreui-angular/src/lib/modal/modal-dismiss/modal-toggle.directive.ts +++ b/projects/coreui-angular/src/lib/modal/modal-dismiss/modal-toggle.directive.ts @@ -1,4 +1,4 @@ -import { Directive, inject, input } from '@angular/core'; +import { Directive, ElementRef, inject, input } from '@angular/core'; import { ModalService } from '../modal.service'; @@ -9,6 +9,7 @@ import { ModalService } from '../modal.service'; } }) export class ModalToggleDirective { + readonly #elementRef = inject(ElementRef); readonly #modalService = inject(ModalService); /** @@ -19,6 +20,6 @@ export class ModalToggleDirective { dismiss($event: Event): void { $event.preventDefault(); - this.#modalService.toggle({ show: 'toggle', id: this.toggle() }); + this.#modalService.toggle({ show: 'toggle', id: this.toggle(), trigger: this.#elementRef.nativeElement }); } } diff --git a/projects/coreui-angular/src/lib/modal/modal.service.ts b/projects/coreui-angular/src/lib/modal/modal.service.ts index 12ced01e..f3e6dfe3 100644 --- a/projects/coreui-angular/src/lib/modal/modal.service.ts +++ b/projects/coreui-angular/src/lib/modal/modal.service.ts @@ -6,6 +6,7 @@ export interface IModalAction { show?: boolean | 'toggle'; modal?: ModalComponent; id?: string; + trigger?: HTMLElement; } @Injectable({ diff --git a/projects/coreui-angular/src/lib/modal/modal/modal.component.html b/projects/coreui-angular/src/lib/modal/modal/modal.component.html index 820ce543..f9a90b0b 100644 --- a/projects/coreui-angular/src/lib/modal/modal/modal.component.html +++ b/projects/coreui-angular/src/lib/modal/modal/modal.component.html @@ -5,7 +5,7 @@ [scrollable]="scrollable()" [size]="size()"> -
+
diff --git a/projects/coreui-angular/src/lib/modal/modal/modal.component.spec.ts b/projects/coreui-angular/src/lib/modal/modal/modal.component.spec.ts index 5fed8de9..2e895b5e 100644 --- a/projects/coreui-angular/src/lib/modal/modal/modal.component.spec.ts +++ b/projects/coreui-angular/src/lib/modal/modal/modal.component.spec.ts @@ -1,7 +1,10 @@ /// +import { CdkTrapFocus } from '@angular/cdk/a11y'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { ModalComponent } from './modal.component'; +import { ModalService } from '../modal.service'; import { DOCUMENT } from '@angular/core'; describe('ModalComponent', () => { @@ -195,6 +198,96 @@ describe('ModalComponent', () => { expect(component.visible()).toBe(true); }); + describe('focus return', () => { + let inside: HTMLButtonElement; + let input: HTMLInputElement; + let service: ModalService; + let trigger: HTMLButtonElement; + + const toggle = async (show: boolean | 'toggle', toggleTrigger?: HTMLElement) => { + service.toggle({ show, modal: component, trigger: toggleTrigger }); + fixture.detectChanges(); + await vi.runAllTimersAsync(); + }; + + beforeEach(() => { + service = TestBed.inject(ModalService); + inside = document.createElement('button'); + fixture.nativeElement.append(inside); + 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 () => { + input.focus(); + await toggle('toggle', trigger); + inside.focus(); + await toggle('toggle', inside); + expect(document.activeElement).toBe(trigger); + }); + + it('should return focus to the element focused before opening without a toggle', async () => { + input.focus(); + fixture.componentRef.setInput('visible', true); + fixture.detectChanges(); + await vi.runAllTimersAsync(); + inside.focus(); + fixture.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); + inside.focus(); + trigger.disabled = true; + await toggle(false); + expect(document.activeElement).toBe(input); + }); + + it('should return focus when destroyed while open', async () => { + await toggle(true, trigger); + inside.focus(); + fixture.destroy(); + expect(document.activeElement).toBe(trigger); + }); + + it('should not move focus that left the modal', async () => { + await toggle(true, trigger); + input.focus(); + await toggle(false); + expect(document.activeElement).toBe(input); + }); + + it('should not reuse the toggle of an earlier opening', async () => { + await toggle(true, trigger); + inside.focus(); + await toggle(false); + input.focus(); + fixture.componentRef.setInput('visible', true); + fixture.detectChanges(); + await vi.runAllTimersAsync(); + inside.focus(); + fixture.componentRef.setInput('visible', false); + fixture.detectChanges(); + expect(document.activeElement).toBe(input); + }); + + it('should not let the focus trap capture focus while open', async () => { + await toggle(true); + const trap = fixture.debugElement.query(By.directive(CdkTrapFocus)).injector.get(CdkTrapFocus); + expect(trap.enabled).toBe(true); + expect(trap.autoCapture).toBe(false); + }); + }); + describe('with portal', () => { let container: HTMLDivElement; let originalParent: HTMLElement; diff --git a/projects/coreui-angular/src/lib/modal/modal/modal.component.ts b/projects/coreui-angular/src/lib/modal/modal/modal.component.ts index 2fea5184..461c88ba 100644 --- a/projects/coreui-angular/src/lib/modal/modal/modal.component.ts +++ b/projects/coreui-angular/src/lib/modal/modal/modal.component.ts @@ -1,6 +1,7 @@ import { A11yModule, FocusMonitor } from '@angular/cdk/a11y'; import { BooleanInput } from '@angular/cdk/coercion'; import { DomPortal, DomPortalOutlet } from '@angular/cdk/portal'; +import { isPlatformBrowser } from '@angular/common'; import { AfterViewInit, booleanAttribute, @@ -16,6 +17,7 @@ import { OnDestroy, OnInit, output, + PLATFORM_ID, Renderer2, signal, untracked, @@ -27,6 +29,7 @@ import { ModalService } from '../modal.service'; import { BackdropService } from '../../backdrop/backdrop.service'; import { ModalContentComponent } from '../modal-content/modal-content.component'; import { ModalDialogComponent } from '../modal-dialog/modal-dialog.component'; +import { restoreFocus } from '../../shared/focus.utils'; @Component({ selector: 'c-modal', @@ -60,6 +63,7 @@ export class ModalComponent implements OnInit, OnDestroy, AfterViewInit { readonly #destroyRef = inject(DestroyRef); readonly #focusMonitor = inject(FocusMonitor); + readonly #platformId = inject(PLATFORM_ID); /** * Align the modal in the center or top of the screen. @@ -197,15 +201,15 @@ export class ModalComponent implements OnInit, OnDestroy, AfterViewInit { }); }); - readonly #activeElement = signal(null); + #focusBeforeShow: HTMLElement | null = null; + #trigger: HTMLElement | null = null; readonly #visibleEffect = effect(() => { const visible = this.visible(); const afterViewInit = this.#afterViewInit(); untracked(() => { if (visible && afterViewInit) { - this.#activeElement.set(this.#document.activeElement as HTMLElement); - // this.#activeElement()?.blur(); + this.#focusBeforeShow = this.#document.activeElement as HTMLElement | null; setTimeout(() => { const focusable = this.modalContentRef()?.nativeElement.querySelectorAll( '[tabindex]:not([tabindex="-1"]), button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled])' @@ -215,14 +219,7 @@ export class ModalComponent implements OnInit, OnDestroy, AfterViewInit { } }); } else { - const activeElement = this.#activeElement(); - if (activeElement && this.#document.contains(activeElement)) { - this.#focusMonitor.focusVia(activeElement, 'keyboard'); - setTimeout(() => { - // this.#activeElement()?.focus(); - this.#activeElement.set(null); - }); - } + this.#restoreFocus(); } }); }); @@ -343,6 +340,9 @@ export class ModalComponent implements OnInit, OnDestroy, AfterViewInit { } ngOnDestroy(): void { + if (this.visible()) { + this.#restoreFocus(); + } this.#modalService.toggle({ show: false, modal: this }); this.#afterViewInit.set(false); this.setBackdrop(false); @@ -353,7 +353,12 @@ export class ModalComponent implements OnInit, OnDestroy, AfterViewInit { this.#modalService.modalState$.pipe(takeUntilDestroyed(this.#destroyRef)).subscribe((action) => { if (this === action.modal || this.id === action.id) { if ('show' in action) { - this.visible.update((visible) => (action?.show === 'toggle' ? !visible : 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); } } else { if (this.visible()) { @@ -363,6 +368,15 @@ export class ModalComponent implements OnInit, OnDestroy, AfterViewInit { }); } + #restoreFocus(): void { + const candidates = [this.#trigger, this.#focusBeforeShow]; + this.#trigger = null; + this.#focusBeforeShow = null; + if (isPlatformBrowser(this.#platformId)) { + restoreFocus(this.#hostElement.nativeElement, candidates); + } + } + private setBackdrop(setBackdrop: boolean): void { this.#activeBackdrop = setBackdrop ? this.#backdropService.setBackdrop('modal') 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 7fd23e60..aaddd508 100644 --- a/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.ts +++ b/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.ts @@ -29,6 +29,7 @@ import { filter } from 'rxjs/operators'; import { BackdropService } from '../../backdrop/backdrop.service'; import { ThemeDirective } from '../../shared'; +import { restoreFocus } from '../../shared/focus.utils'; import { OffcanvasService } from '../offcanvas.service'; let nextId = 0; @@ -364,21 +365,8 @@ export class OffcanvasComponent implements OnInit, OnDestroy { 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; - } - } + if (isPlatformBrowser(this.#platformId)) { + restoreFocus(this.#hostElement.nativeElement, candidates); } } diff --git a/projects/coreui-angular/src/lib/shared/focus.utils.spec.ts b/projects/coreui-angular/src/lib/shared/focus.utils.spec.ts new file mode 100644 index 00000000..d675c38f --- /dev/null +++ b/projects/coreui-angular/src/lib/shared/focus.utils.spec.ts @@ -0,0 +1,79 @@ +/// +import { restoreFocus } from './focus.utils'; + +describe('restoreFocus', () => { + let overlay: HTMLDivElement; + let inside: HTMLButtonElement; + let first: HTMLButtonElement; + let second: HTMLInputElement; + let outside: HTMLButtonElement; + + beforeEach(() => { + overlay = document.createElement('div'); + inside = document.createElement('button'); + overlay.append(inside); + first = document.createElement('button'); + second = document.createElement('input'); + outside = document.createElement('button'); + document.body.append(overlay, first, second, outside); + }); + + afterEach(() => { + overlay.remove(); + first.remove(); + second.remove(); + outside.remove(); + }); + + it('should focus the first candidate when focus is inside the overlay', () => { + inside.focus(); + expect(restoreFocus(overlay, [first, second])).toBe(true); + expect(document.activeElement).toBe(first); + }); + + it('should focus the first candidate when focus is on the body', () => { + expect(document.activeElement).toBe(document.body); + expect(restoreFocus(overlay, [first, second])).toBe(true); + expect(document.activeElement).toBe(first); + }); + + it('should not move focus that is on an element outside the overlay', () => { + outside.focus(); + expect(restoreFocus(overlay, [first, second])).toBe(false); + expect(document.activeElement).toBe(outside); + }); + + it('should skip a candidate that cannot take focus', () => { + first.disabled = true; + inside.focus(); + expect(restoreFocus(overlay, [first, second])).toBe(true); + expect(document.activeElement).toBe(second); + }); + + it('should skip missing and detached candidates', () => { + first.remove(); + const focus = vi.spyOn(first, 'focus'); + inside.focus(); + expect(restoreFocus(overlay, [null, undefined, first, second])).toBe(true); + expect(focus).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(second); + }); + + it('should leave focus in place when the only candidate is the body', () => { + inside.focus(); + expect(restoreFocus(overlay, [document.body])).toBe(false); + expect(document.activeElement).toBe(inside); + }); + + it('should focus without scrolling', () => { + const focus = vi.spyOn(first, 'focus'); + restoreFocus(overlay, [first]); + expect(focus).toHaveBeenCalledWith({ preventScroll: true }); + }); + + it('should return false when no candidate takes focus', () => { + inside.focus(); + expect(restoreFocus(overlay, [])).toBe(false); + expect(document.activeElement).toBe(inside); + }); +}); diff --git a/projects/coreui-angular/src/lib/shared/focus.utils.ts b/projects/coreui-angular/src/lib/shared/focus.utils.ts new file mode 100644 index 00000000..f20ab55e --- /dev/null +++ b/projects/coreui-angular/src/lib/shared/focus.utils.ts @@ -0,0 +1,23 @@ +/** + * Returns focus after an overlay closes: to the first candidate that takes it, without scrolling. + * Does nothing when focus has already moved to an element outside the overlay. + * @param overlay the closing overlay host + * @param candidates focus targets in order of preference, e.g. the opening toggle, then the element focused at open + * @returns whether a candidate took focus + */ +export const restoreFocus = (overlay: HTMLElement, candidates: readonly (HTMLElement | null | undefined)[]): boolean => { + const document = overlay.ownerDocument; + const active = document.activeElement; + if (active && active !== document.body && !overlay.contains(active)) { + return false; + } + for (const target of candidates) { + if (target?.isConnected) { + target.focus({ preventScroll: true }); + if (document.activeElement === target) { + return true; + } + } + } + return false; +}; From 7f0dcbbbf702b7e1f687e596e102d28f872f1890 Mon Sep 17 00:00:00 2001 From: xidedix Date: Thu, 8 Oct 2026 01:26:19 +0200 Subject: [PATCH 2/7] fix(Modal, Offcanvas): tabbable fallback on open, focus return on same-tick destroy and inside shadow roots --- .../lib/modal/modal/modal.component.spec.ts | 42 +++++++++++++++++++ .../src/lib/modal/modal/modal.component.ts | 8 +++- .../offcanvas/offcanvas.component.spec.ts | 7 ++++ .../offcanvas/offcanvas.component.ts | 2 +- .../src/lib/shared/focus.utils.spec.ts | 15 +++++++ .../src/lib/shared/focus.utils.ts | 9 ++-- 6 files changed, 76 insertions(+), 7 deletions(-) diff --git a/projects/coreui-angular/src/lib/modal/modal/modal.component.spec.ts b/projects/coreui-angular/src/lib/modal/modal/modal.component.spec.ts index 2e895b5e..11213ca6 100644 --- a/projects/coreui-angular/src/lib/modal/modal/modal.component.spec.ts +++ b/projects/coreui-angular/src/lib/modal/modal/modal.component.spec.ts @@ -280,6 +280,48 @@ describe('ModalComponent', () => { expect(document.activeElement).toBe(input); }); + it('should move focus to the first focusable element on open', async () => { + const content = fixture.nativeElement.querySelector('c-modal-content > div:not(.cdk-focus-trap-anchor)'); + const first = document.createElement('button'); + content.append(first); + await toggle(true, trigger); + expect(document.activeElement).toBe(first); + }); + + it('should fall back to the first tabbable element when the first match cannot take focus', async () => { + const content = fixture.nativeElement.querySelector('c-modal-content > div:not(.cdk-focus-trap-anchor)'); + const hidden = document.createElement('input'); + hidden.type = 'hidden'; + content.append(hidden); + const trap = fixture.debugElement.query(By.directive(CdkTrapFocus)).injector.get(CdkTrapFocus); + const fallback = vi.spyOn(trap.focusTrap, 'focusFirstTabbableElement'); + await toggle(true, trigger); + expect(fallback).toHaveBeenCalled(); + }); + + it('should not use the fallback when the first match takes focus', async () => { + const content = fixture.nativeElement.querySelector('c-modal-content > div:not(.cdk-focus-trap-anchor)'); + content.append(document.createElement('button')); + const trap = fixture.debugElement.query(By.directive(CdkTrapFocus)).injector.get(CdkTrapFocus); + const fallback = vi.spyOn(trap.focusTrap, 'focusFirstTabbableElement'); + await toggle(true, trigger); + expect(fallback).not.toHaveBeenCalled(); + }); + + it('should return focus when closed and destroyed in the same tick', async () => { + await toggle(true, trigger); + inside.focus(); + service.toggle({ show: false, modal: component }); + fixture.destroy(); + expect(document.activeElement).toBe(trigger); + }); + + it('should render the dialog role only while open', async () => { + expect(fixture.nativeElement.getAttribute('role')).toBeNull(); + await toggle(true); + expect(fixture.nativeElement.getAttribute('role')).toBe('dialog'); + }); + it('should not let the focus trap capture focus while open', async () => { await toggle(true); const trap = fixture.debugElement.query(By.directive(CdkTrapFocus)).injector.get(CdkTrapFocus); diff --git a/projects/coreui-angular/src/lib/modal/modal/modal.component.ts b/projects/coreui-angular/src/lib/modal/modal/modal.component.ts index 461c88ba..4d6bb193 100644 --- a/projects/coreui-angular/src/lib/modal/modal/modal.component.ts +++ b/projects/coreui-angular/src/lib/modal/modal/modal.component.ts @@ -1,4 +1,4 @@ -import { A11yModule, FocusMonitor } from '@angular/cdk/a11y'; +import { A11yModule, CdkTrapFocus, FocusMonitor } from '@angular/cdk/a11y'; import { BooleanInput } from '@angular/cdk/coercion'; import { DomPortal, DomPortalOutlet } from '@angular/cdk/portal'; import { isPlatformBrowser } from '@angular/common'; @@ -217,6 +217,9 @@ export class ModalComponent implements OnInit, OnDestroy, AfterViewInit { if (focusable?.length) { this.#focusMonitor.focusVia(focusable[0], 'keyboard'); } + if (!this.modalContentRef()?.nativeElement.contains(this.#document.activeElement)) { + this.focusTrap()?.focusTrap.focusFirstTabbableElement(); + } }); } else { this.#restoreFocus(); @@ -231,6 +234,7 @@ export class ModalComponent implements OnInit, OnDestroy, AfterViewInit { readonly visibleChange = output(); readonly modalContentRef = viewChild('modalContentRef', { read: ElementRef }); + protected readonly focusTrap = viewChild(CdkTrapFocus); readonly modalDialogRef = viewChild.required(ModalDialogComponent, { read: ElementRef }); readonly #modalDialogEffect = effect((OnCleanup) => { @@ -340,7 +344,7 @@ export class ModalComponent implements OnInit, OnDestroy, AfterViewInit { } ngOnDestroy(): void { - if (this.visible()) { + if (this.#trigger || this.#focusBeforeShow) { this.#restoreFocus(); } this.#modalService.toggle({ show: false, modal: this }); 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 95ab94e8..77d1e7af 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 @@ -312,6 +312,13 @@ describe('OffcanvasComponent', () => { expect(document.activeElement).toBe(trigger); }); + it('should return focus when closed and destroyed in the same tick', async () => { + await toggle(true, trigger); + service.toggle({ show: false, id: component.id() }); + fixture.destroy(); + expect(document.activeElement).toBe(trigger); + }); + it('should not move focus that left the panel', async () => { await toggle(true, trigger); input.focus(); 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 aaddd508..c7eb7ca1 100644 --- a/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.ts +++ b/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.ts @@ -299,7 +299,7 @@ export class OffcanvasComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { - if (this.visible()) { + if (this.#trigger || this.#focusBeforeShow) { this.#restoreFocus(); } this.#offcanvasService.toggle({ show: false, id: this.id() }); diff --git a/projects/coreui-angular/src/lib/shared/focus.utils.spec.ts b/projects/coreui-angular/src/lib/shared/focus.utils.spec.ts index d675c38f..0d9d6097 100644 --- a/projects/coreui-angular/src/lib/shared/focus.utils.spec.ts +++ b/projects/coreui-angular/src/lib/shared/focus.utils.spec.ts @@ -71,6 +71,21 @@ describe('restoreFocus', () => { expect(focus).toHaveBeenCalledWith({ preventScroll: true }); }); + it('should read focus inside a shadow root', () => { + const shadowHost = document.createElement('div'); + document.body.append(shadowHost); + const root = shadowHost.attachShadow({ mode: 'open' }); + const shadowOverlay = document.createElement('div'); + const shadowInside = document.createElement('button'); + const shadowTrigger = document.createElement('button'); + shadowOverlay.append(shadowInside); + root.append(shadowOverlay, shadowTrigger); + shadowInside.focus(); + expect(restoreFocus(shadowOverlay, [shadowTrigger])).toBe(true); + expect(root.activeElement).toBe(shadowTrigger); + shadowHost.remove(); + }); + it('should return false when no candidate takes focus', () => { inside.focus(); expect(restoreFocus(overlay, [])).toBe(false); diff --git a/projects/coreui-angular/src/lib/shared/focus.utils.ts b/projects/coreui-angular/src/lib/shared/focus.utils.ts index f20ab55e..97f7563c 100644 --- a/projects/coreui-angular/src/lib/shared/focus.utils.ts +++ b/projects/coreui-angular/src/lib/shared/focus.utils.ts @@ -1,3 +1,5 @@ +import { _getFocusedElementPierceShadowDom } from '@angular/cdk/platform'; + /** * Returns focus after an overlay closes: to the first candidate that takes it, without scrolling. * Does nothing when focus has already moved to an element outside the overlay. @@ -6,15 +8,14 @@ * @returns whether a candidate took focus */ export const restoreFocus = (overlay: HTMLElement, candidates: readonly (HTMLElement | null | undefined)[]): boolean => { - const document = overlay.ownerDocument; - const active = document.activeElement; - if (active && active !== document.body && !overlay.contains(active)) { + const active = _getFocusedElementPierceShadowDom(); + if (active && active !== overlay.ownerDocument.body && !overlay.contains(active)) { return false; } for (const target of candidates) { if (target?.isConnected) { target.focus({ preventScroll: true }); - if (document.activeElement === target) { + if (_getFocusedElementPierceShadowDom() === target) { return true; } } From 7f2c7f9292bc13b1f33cbede6f331a138bb912c3 Mon Sep 17 00:00:00 2001 From: xidedix Date: Thu, 8 Oct 2026 01:30:27 +0200 Subject: [PATCH 3/7] fix(Modal, Offcanvas): read focus from the document again, as the other editions do --- .../src/lib/shared/focus.utils.spec.ts | 15 --------------- .../coreui-angular/src/lib/shared/focus.utils.ts | 9 ++++----- 2 files changed, 4 insertions(+), 20 deletions(-) diff --git a/projects/coreui-angular/src/lib/shared/focus.utils.spec.ts b/projects/coreui-angular/src/lib/shared/focus.utils.spec.ts index 0d9d6097..d675c38f 100644 --- a/projects/coreui-angular/src/lib/shared/focus.utils.spec.ts +++ b/projects/coreui-angular/src/lib/shared/focus.utils.spec.ts @@ -71,21 +71,6 @@ describe('restoreFocus', () => { expect(focus).toHaveBeenCalledWith({ preventScroll: true }); }); - it('should read focus inside a shadow root', () => { - const shadowHost = document.createElement('div'); - document.body.append(shadowHost); - const root = shadowHost.attachShadow({ mode: 'open' }); - const shadowOverlay = document.createElement('div'); - const shadowInside = document.createElement('button'); - const shadowTrigger = document.createElement('button'); - shadowOverlay.append(shadowInside); - root.append(shadowOverlay, shadowTrigger); - shadowInside.focus(); - expect(restoreFocus(shadowOverlay, [shadowTrigger])).toBe(true); - expect(root.activeElement).toBe(shadowTrigger); - shadowHost.remove(); - }); - it('should return false when no candidate takes focus', () => { inside.focus(); expect(restoreFocus(overlay, [])).toBe(false); diff --git a/projects/coreui-angular/src/lib/shared/focus.utils.ts b/projects/coreui-angular/src/lib/shared/focus.utils.ts index 97f7563c..f20ab55e 100644 --- a/projects/coreui-angular/src/lib/shared/focus.utils.ts +++ b/projects/coreui-angular/src/lib/shared/focus.utils.ts @@ -1,5 +1,3 @@ -import { _getFocusedElementPierceShadowDom } from '@angular/cdk/platform'; - /** * Returns focus after an overlay closes: to the first candidate that takes it, without scrolling. * Does nothing when focus has already moved to an element outside the overlay. @@ -8,14 +6,15 @@ import { _getFocusedElementPierceShadowDom } from '@angular/cdk/platform'; * @returns whether a candidate took focus */ export const restoreFocus = (overlay: HTMLElement, candidates: readonly (HTMLElement | null | undefined)[]): boolean => { - const active = _getFocusedElementPierceShadowDom(); - if (active && active !== overlay.ownerDocument.body && !overlay.contains(active)) { + const document = overlay.ownerDocument; + const active = document.activeElement; + if (active && active !== document.body && !overlay.contains(active)) { return false; } for (const target of candidates) { if (target?.isConnected) { target.focus({ preventScroll: true }); - if (_getFocusedElementPierceShadowDom() === target) { + if (document.activeElement === target) { return true; } } From 4e8d59a1d4e2afe14ba7060a20b5bcf30e574681 Mon Sep 17 00:00:00 2001 From: xidedix Date: Thu, 8 Oct 2026 01:50:16 +0200 Subject: [PATCH 4/7] fix(Modal): inert alone hides a closed modal, no aria-hidden over a focused element --- .../src/lib/modal/modal/modal.component.spec.ts | 6 +++--- .../coreui-angular/src/lib/modal/modal/modal.component.ts | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/projects/coreui-angular/src/lib/modal/modal/modal.component.spec.ts b/projects/coreui-angular/src/lib/modal/modal/modal.component.spec.ts index 11213ca6..f8cc284b 100644 --- a/projects/coreui-angular/src/lib/modal/modal/modal.component.spec.ts +++ b/projects/coreui-angular/src/lib/modal/modal/modal.component.spec.ts @@ -68,9 +68,9 @@ describe('ModalComponent', () => { expect(fixture.nativeElement.classList.contains('show')).toBe(false); }); - it('should toggle inert and aria-hidden with visibility', async () => { + it('should toggle inert with visibility and never set aria-hidden', async () => { expect(fixture.nativeElement.inert).toBe(true); - expect(fixture.nativeElement.getAttribute('aria-hidden')).toBe('true'); + expect(fixture.nativeElement.getAttribute('aria-hidden')).toBeNull(); fixture.componentRef.setInput('visible', true); fixture.detectChanges(); @@ -84,7 +84,7 @@ describe('ModalComponent', () => { await vi.runAllTimersAsync(); fixture.detectChanges(); expect(fixture.nativeElement.inert).toBe(true); - expect(fixture.nativeElement.getAttribute('aria-hidden')).toBe('true'); + expect(fixture.nativeElement.getAttribute('aria-hidden')).toBeNull(); }); it('should close modal on Escape key press if keyboard is enabled', async () => { diff --git a/projects/coreui-angular/src/lib/modal/modal/modal.component.ts b/projects/coreui-angular/src/lib/modal/modal/modal.component.ts index 4d6bb193..bb677f57 100644 --- a/projects/coreui-angular/src/lib/modal/modal/modal.component.ts +++ b/projects/coreui-angular/src/lib/modal/modal/modal.component.ts @@ -43,7 +43,6 @@ import { restoreFocus } from '../../shared/focus.utils'; '[inert]': 'ariaHidden()', '[attr.id]': 'id', '[aria-modal]': 'ariaModal()', - '[aria-hidden]': 'ariaHidden()', '[attr.tabindex]': '-1', '(mousedown)': 'onMouseDownHandler($event)', '(click)': 'onClickHandler($event)', From c227c8bd4749b592b143066546179e5576514a9b Mon Sep 17 00:00:00 2001 From: xidedix Date: Thu, 8 Oct 2026 02:01:44 +0200 Subject: [PATCH 5/7] fix(Modal): initial focus through the focus trap (cdkFocusInitial, then first tabbable), else the modal itself --- .../lib/modal/modal/modal.component.spec.ts | 34 +++++++++---------- .../src/lib/modal/modal/modal.component.ts | 13 ++----- 2 files changed, 19 insertions(+), 28 deletions(-) diff --git a/projects/coreui-angular/src/lib/modal/modal/modal.component.spec.ts b/projects/coreui-angular/src/lib/modal/modal/modal.component.spec.ts index f8cc284b..31109ab8 100644 --- a/projects/coreui-angular/src/lib/modal/modal/modal.component.spec.ts +++ b/projects/coreui-angular/src/lib/modal/modal/modal.component.spec.ts @@ -280,32 +280,30 @@ describe('ModalComponent', () => { expect(document.activeElement).toBe(input); }); - it('should move focus to the first focusable element on open', async () => { - const content = fixture.nativeElement.querySelector('c-modal-content > div:not(.cdk-focus-trap-anchor)'); - const first = document.createElement('button'); - content.append(first); + it('should hand initial focus to the focus trap on open', async () => { + const trap = fixture.debugElement.query(By.directive(CdkTrapFocus)).injector.get(CdkTrapFocus); + const initial = vi.spyOn(trap.focusTrap, 'focusInitialElement').mockReturnValue(true); await toggle(true, trigger); - expect(document.activeElement).toBe(first); + expect(initial).toHaveBeenCalledTimes(1); + expect(document.activeElement).not.toBe(fixture.nativeElement); }); - it('should fall back to the first tabbable element when the first match cannot take focus', async () => { - const content = fixture.nativeElement.querySelector('c-modal-content > div:not(.cdk-focus-trap-anchor)'); - const hidden = document.createElement('input'); - hidden.type = 'hidden'; - content.append(hidden); + it('should focus the modal itself when nothing in it can take focus', async () => { const trap = fixture.debugElement.query(By.directive(CdkTrapFocus)).injector.get(CdkTrapFocus); - const fallback = vi.spyOn(trap.focusTrap, 'focusFirstTabbableElement'); + vi.spyOn(trap.focusTrap, 'focusInitialElement').mockReturnValue(false); + trigger.focus(); await toggle(true, trigger); - expect(fallback).toHaveBeenCalled(); + expect(document.activeElement).toBe(fixture.nativeElement); }); - it('should not use the fallback when the first match takes focus', async () => { - const content = fixture.nativeElement.querySelector('c-modal-content > div:not(.cdk-focus-trap-anchor)'); - content.append(document.createElement('button')); + it('should move initial focus only after the modal has rendered', async () => { const trap = fixture.debugElement.query(By.directive(CdkTrapFocus)).injector.get(CdkTrapFocus); - const fallback = vi.spyOn(trap.focusTrap, 'focusFirstTabbableElement'); - await toggle(true, trigger); - expect(fallback).not.toHaveBeenCalled(); + const initial = vi.spyOn(trap.focusTrap, 'focusInitialElement'); + service.toggle({ show: true, modal: component, trigger }); + fixture.detectChanges(); + expect(initial).not.toHaveBeenCalled(); + await vi.runAllTimersAsync(); + expect(initial).toHaveBeenCalledTimes(1); }); it('should return focus when closed and destroyed in the same tick', async () => { diff --git a/projects/coreui-angular/src/lib/modal/modal/modal.component.ts b/projects/coreui-angular/src/lib/modal/modal/modal.component.ts index bb677f57..9e8c5a97 100644 --- a/projects/coreui-angular/src/lib/modal/modal/modal.component.ts +++ b/projects/coreui-angular/src/lib/modal/modal/modal.component.ts @@ -1,4 +1,4 @@ -import { A11yModule, CdkTrapFocus, FocusMonitor } from '@angular/cdk/a11y'; +import { A11yModule, CdkTrapFocus } from '@angular/cdk/a11y'; import { BooleanInput } from '@angular/cdk/coercion'; import { DomPortal, DomPortalOutlet } from '@angular/cdk/portal'; import { isPlatformBrowser } from '@angular/common'; @@ -61,7 +61,6 @@ export class ModalComponent implements OnInit, OnDestroy, AfterViewInit { readonly #backdropService = inject(BackdropService); readonly #destroyRef = inject(DestroyRef); - readonly #focusMonitor = inject(FocusMonitor); readonly #platformId = inject(PLATFORM_ID); /** @@ -210,14 +209,8 @@ export class ModalComponent implements OnInit, OnDestroy, AfterViewInit { if (visible && afterViewInit) { this.#focusBeforeShow = this.#document.activeElement as HTMLElement | null; setTimeout(() => { - const focusable = this.modalContentRef()?.nativeElement.querySelectorAll( - '[tabindex]:not([tabindex="-1"]), button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled])' - ); - if (focusable?.length) { - this.#focusMonitor.focusVia(focusable[0], 'keyboard'); - } - if (!this.modalContentRef()?.nativeElement.contains(this.#document.activeElement)) { - this.focusTrap()?.focusTrap.focusFirstTabbableElement(); + if (!this.focusTrap()?.focusTrap.focusInitialElement()) { + this.#hostElement.nativeElement.focus(); } }); } else { From fef388f1ce20474a7768996123e3eb3b8e222999 Mon Sep 17 00:00:00 2001 From: xidedix Date: Thu, 8 Oct 2026 02:22:34 +0200 Subject: [PATCH 6/7] fix(Modal): a modal closed for another one hands its focus targets on, so focus survives switching back --- .../src/lib/modal/modal.service.spec.ts | 11 +++++ .../src/lib/modal/modal.service.ts | 3 +- .../lib/modal/modal/modal.component.spec.ts | 41 +++++++++++++++++++ .../src/lib/modal/modal/modal.component.ts | 12 +++++- .../src/lib/shared/focus.utils.spec.ts | 8 ++++ .../src/lib/shared/focus.utils.ts | 4 +- 6 files changed, 74 insertions(+), 5 deletions(-) diff --git a/projects/coreui-angular/src/lib/modal/modal.service.spec.ts b/projects/coreui-angular/src/lib/modal/modal.service.spec.ts index 0ad21ad7..315e418d 100644 --- a/projects/coreui-angular/src/lib/modal/modal.service.spec.ts +++ b/projects/coreui-angular/src/lib/modal/modal.service.spec.ts @@ -13,4 +13,15 @@ describe('ModalService', () => { it('should be created', () => { expect(service).toBeTruthy(); }); + + it('should hand each subscriber a copy of the action', () => { + const received: object[] = []; + service.modalState$.subscribe((value) => received.push(value)); + const action = { show: true, id: 'm' }; + service.toggle(action); + service.toggle(action); + expect(received[0]).toEqual(action); + expect(received[0]).not.toBe(action); + expect(received[0]).not.toBe(received[1]); + }); }); diff --git a/projects/coreui-angular/src/lib/modal/modal.service.ts b/projects/coreui-angular/src/lib/modal/modal.service.ts index f3e6dfe3..93a5a8bf 100644 --- a/projects/coreui-angular/src/lib/modal/modal.service.ts +++ b/projects/coreui-angular/src/lib/modal/modal.service.ts @@ -5,6 +5,7 @@ import { ModalComponent } from './modal/modal.component'; export interface IModalAction { show?: boolean | 'toggle'; modal?: ModalComponent; + focusFallback?: (HTMLElement | null)[]; id?: string; trigger?: HTMLElement; } @@ -17,6 +18,6 @@ export class ModalService { readonly modalState$ = this.#modalState.asObservable(); toggle(action: IModalAction): void { - this.#modalState.next(action); + this.#modalState.next({ ...action }); } } diff --git a/projects/coreui-angular/src/lib/modal/modal/modal.component.spec.ts b/projects/coreui-angular/src/lib/modal/modal/modal.component.spec.ts index 31109ab8..a431bc15 100644 --- a/projects/coreui-angular/src/lib/modal/modal/modal.component.spec.ts +++ b/projects/coreui-angular/src/lib/modal/modal/modal.component.spec.ts @@ -320,6 +320,47 @@ describe('ModalComponent', () => { expect(fixture.nativeElement.getAttribute('role')).toBe('dialog'); }); + it('should hand its focus candidates to the action that closes it for another modal', async () => { + await toggle(true, trigger); + inside.focus(); + const received: { focusFallback?: (HTMLElement | null)[] }[] = []; + service.modalState$.subscribe((action) => received.push(action)); + service.toggle({ show: true, id: 'other-modal', trigger: inside }); + expect(received[0].focusFallback?.includes(trigger)).toBe(true); + fixture.detectChanges(); + await vi.runAllTimersAsync(); + }); + + it('should return focus to a fallback handed over by the action that opened it', async () => { + (document.activeElement as HTMLElement | null)?.blur(); + trigger.disabled = true; + service.toggle({ show: true, modal: component, trigger, focusFallback: [input] }); + fixture.detectChanges(); + await vi.runAllTimersAsync(); + inside.focus(); + await toggle(false); + expect(document.activeElement).toBe(input); + }); + + it('should not reuse the fallback of an earlier opening', async () => { + const stale = document.createElement('button'); + document.body.append(stale); + service.toggle({ show: true, modal: component, focusFallback: [stale] }); + fixture.detectChanges(); + await vi.runAllTimersAsync(); + inside.focus(); + await toggle(false); + inside.focus(); + fixture.componentRef.setInput('visible', true); + fixture.detectChanges(); + await vi.runAllTimersAsync(); + inside.focus(); + fixture.componentRef.setInput('visible', false); + fixture.detectChanges(); + expect(document.activeElement).not.toBe(stale); + stale.remove(); + }); + it('should not let the focus trap capture focus while open', async () => { await toggle(true); const trap = fixture.debugElement.query(By.directive(CdkTrapFocus)).injector.get(CdkTrapFocus); diff --git a/projects/coreui-angular/src/lib/modal/modal/modal.component.ts b/projects/coreui-angular/src/lib/modal/modal/modal.component.ts index 9e8c5a97..26027ef4 100644 --- a/projects/coreui-angular/src/lib/modal/modal/modal.component.ts +++ b/projects/coreui-angular/src/lib/modal/modal/modal.component.ts @@ -25,7 +25,7 @@ import { } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { ModalService } from '../modal.service'; +import { IModalAction, ModalService } from '../modal.service'; import { BackdropService } from '../../backdrop/backdrop.service'; import { ModalContentComponent } from '../modal-content/modal-content.component'; import { ModalDialogComponent } from '../modal-dialog/modal-dialog.component'; @@ -200,6 +200,7 @@ export class ModalComponent implements OnInit, OnDestroy, AfterViewInit { }); #focusBeforeShow: HTMLElement | null = null; + #openAction: IModalAction | null = null; #trigger: HTMLElement | null = null; readonly #visibleEffect = effect(() => { @@ -353,21 +354,28 @@ export class ModalComponent implements OnInit, OnDestroy, AfterViewInit { const show = action?.show === 'toggle' ? !visible : !!action.show; if (show && !visible) { this.#trigger = action.trigger ?? null; + this.#openAction = action; } this.visible.set(show); } } else { if (this.visible()) { + (action.focusFallback ??= []).push(...this.#focusCandidates()); this.visible.set(false); } } }); } + #focusCandidates(): (HTMLElement | null)[] { + return [this.#trigger, this.#focusBeforeShow, ...(this.#openAction?.focusFallback ?? [])]; + } + #restoreFocus(): void { - const candidates = [this.#trigger, this.#focusBeforeShow]; + const candidates = this.#focusCandidates(); this.#trigger = null; this.#focusBeforeShow = null; + this.#openAction = null; if (isPlatformBrowser(this.#platformId)) { restoreFocus(this.#hostElement.nativeElement, candidates); } diff --git a/projects/coreui-angular/src/lib/shared/focus.utils.spec.ts b/projects/coreui-angular/src/lib/shared/focus.utils.spec.ts index d675c38f..643a2768 100644 --- a/projects/coreui-angular/src/lib/shared/focus.utils.spec.ts +++ b/projects/coreui-angular/src/lib/shared/focus.utils.spec.ts @@ -65,6 +65,14 @@ describe('restoreFocus', () => { expect(document.activeElement).toBe(inside); }); + it('should skip a candidate inside the overlay', () => { + inside.focus(); + const insideFocus = vi.spyOn(inside, 'focus'); + expect(restoreFocus(overlay, [inside, second])).toBe(true); + expect(insideFocus).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(second); + }); + it('should focus without scrolling', () => { const focus = vi.spyOn(first, 'focus'); restoreFocus(overlay, [first]); diff --git a/projects/coreui-angular/src/lib/shared/focus.utils.ts b/projects/coreui-angular/src/lib/shared/focus.utils.ts index f20ab55e..cbbfa467 100644 --- a/projects/coreui-angular/src/lib/shared/focus.utils.ts +++ b/projects/coreui-angular/src/lib/shared/focus.utils.ts @@ -1,6 +1,6 @@ /** * Returns focus after an overlay closes: to the first candidate that takes it, without scrolling. - * Does nothing when focus has already moved to an element outside the overlay. + * Does nothing when focus has already moved to an element outside the overlay; skips candidates inside the overlay. * @param overlay the closing overlay host * @param candidates focus targets in order of preference, e.g. the opening toggle, then the element focused at open * @returns whether a candidate took focus @@ -12,7 +12,7 @@ export const restoreFocus = (overlay: HTMLElement, candidates: readonly (HTMLEle return false; } for (const target of candidates) { - if (target?.isConnected) { + if (target?.isConnected && !overlay.contains(target)) { target.focus({ preventScroll: true }); if (document.activeElement === target) { return true; From 71a2d1cb5200392aed0dbb8eff2301734e5dbb15 Mon Sep 17 00:00:00 2001 From: xidedix Date: Thu, 8 Oct 2026 02:34:28 +0200 Subject: [PATCH 7/7] fix(Modal): copy the focus fallback per call, hand over each target once --- .../src/lib/modal/modal.service.spec.ts | 16 ++++++++++++++-- .../src/lib/modal/modal.service.ts | 2 +- .../src/lib/modal/modal/modal.component.spec.ts | 17 ++++++++++++++++- .../src/lib/modal/modal/modal.component.ts | 6 +++++- 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/projects/coreui-angular/src/lib/modal/modal.service.spec.ts b/projects/coreui-angular/src/lib/modal/modal.service.spec.ts index 315e418d..957317ff 100644 --- a/projects/coreui-angular/src/lib/modal/modal.service.spec.ts +++ b/projects/coreui-angular/src/lib/modal/modal.service.spec.ts @@ -14,14 +14,26 @@ describe('ModalService', () => { expect(service).toBeTruthy(); }); - it('should hand each subscriber a copy of the action', () => { + it('should emit one shared copy of the action per call', () => { const received: object[] = []; + const receivedToo: object[] = []; service.modalState$.subscribe((value) => received.push(value)); + service.modalState$.subscribe((value) => receivedToo.push(value)); const action = { show: true, id: 'm' }; service.toggle(action); service.toggle(action); - expect(received[0]).toEqual(action); + expect(received[0]).toEqual({ ...action, focusFallback: [] }); expect(received[0]).not.toBe(action); expect(received[0]).not.toBe(received[1]); + expect(receivedToo[0]).toBe(received[0]); + }); + + it('should not extend the focus fallback array of the caller', () => { + const focusFallback = [document.createElement('button')]; + let received: { focusFallback?: (HTMLElement | null)[] } = {}; + service.modalState$.subscribe((value) => (received = value)); + service.toggle({ show: true, id: 'm', focusFallback }); + received.focusFallback?.push(null); + expect(focusFallback.length).toBe(1); }); }); diff --git a/projects/coreui-angular/src/lib/modal/modal.service.ts b/projects/coreui-angular/src/lib/modal/modal.service.ts index 93a5a8bf..beec4cfd 100644 --- a/projects/coreui-angular/src/lib/modal/modal.service.ts +++ b/projects/coreui-angular/src/lib/modal/modal.service.ts @@ -18,6 +18,6 @@ export class ModalService { readonly modalState$ = this.#modalState.asObservable(); toggle(action: IModalAction): void { - this.#modalState.next({ ...action }); + this.#modalState.next({ ...action, focusFallback: [...(action.focusFallback ?? [])] }); } } diff --git a/projects/coreui-angular/src/lib/modal/modal/modal.component.spec.ts b/projects/coreui-angular/src/lib/modal/modal/modal.component.spec.ts index a431bc15..f0ab34e6 100644 --- a/projects/coreui-angular/src/lib/modal/modal/modal.component.spec.ts +++ b/projects/coreui-angular/src/lib/modal/modal/modal.component.spec.ts @@ -345,22 +345,37 @@ describe('ModalComponent', () => { it('should not reuse the fallback of an earlier opening', async () => { const stale = document.createElement('button'); document.body.append(stale); + (document.activeElement as HTMLElement | null)?.blur(); service.toggle({ show: true, modal: component, focusFallback: [stale] }); fixture.detectChanges(); await vi.runAllTimersAsync(); inside.focus(); await toggle(false); - inside.focus(); + expect(document.activeElement).toBe(stale); + input.focus(); fixture.componentRef.setInput('visible', true); fixture.detectChanges(); await vi.runAllTimersAsync(); inside.focus(); + input.disabled = true; fixture.componentRef.setInput('visible', false); fixture.detectChanges(); expect(document.activeElement).not.toBe(stale); stale.remove(); }); + it('should hand over each focus candidate once', async () => { + await toggle(true, trigger); + inside.focus(); + let received: { focusFallback?: (HTMLElement | null)[] } = {}; + service.modalState$.subscribe((action) => (received = action)); + service.toggle({ show: true, id: 'other-modal', focusFallback: [trigger] }); + expect(received.focusFallback?.filter((target) => target === trigger).length).toBe(1); + expect(new Set(received.focusFallback).size).toBe(received.focusFallback?.length); + fixture.detectChanges(); + await vi.runAllTimersAsync(); + }); + it('should not let the focus trap capture focus while open', async () => { await toggle(true); const trap = fixture.debugElement.query(By.directive(CdkTrapFocus)).injector.get(CdkTrapFocus); diff --git a/projects/coreui-angular/src/lib/modal/modal/modal.component.ts b/projects/coreui-angular/src/lib/modal/modal/modal.component.ts index 26027ef4..8da66632 100644 --- a/projects/coreui-angular/src/lib/modal/modal/modal.component.ts +++ b/projects/coreui-angular/src/lib/modal/modal/modal.component.ts @@ -360,7 +360,11 @@ export class ModalComponent implements OnInit, OnDestroy, AfterViewInit { } } else { if (this.visible()) { - (action.focusFallback ??= []).push(...this.#focusCandidates()); + for (const target of this.#focusCandidates()) { + if (!action.focusFallback?.includes(target)) { + action.focusFallback?.push(target); + } + } this.visible.set(false); } }