+
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..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
@@ -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', () => {
@@ -65,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();
@@ -81,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 () => {
@@ -195,6 +198,192 @@ 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 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(initial).toHaveBeenCalledTimes(1);
+ expect(document.activeElement).not.toBe(fixture.nativeElement);
+ });
+
+ 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);
+ vi.spyOn(trap.focusTrap, 'focusInitialElement').mockReturnValue(false);
+ trigger.focus();
+ await toggle(true, trigger);
+ expect(document.activeElement).toBe(fixture.nativeElement);
+ });
+
+ it('should move initial focus only after the modal has rendered', async () => {
+ const trap = fixture.debugElement.query(By.directive(CdkTrapFocus)).injector.get(CdkTrapFocus);
+ 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 () => {
+ 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 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);
+ (document.activeElement as HTMLElement | null)?.blur();
+ service.toggle({ show: true, modal: component, focusFallback: [stale] });
+ fixture.detectChanges();
+ await vi.runAllTimersAsync();
+ inside.focus();
+ await toggle(false);
+ 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);
+ 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..8da66632 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 { A11yModule, CdkTrapFocus } 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,
@@ -23,10 +25,11 @@ 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';
+import { restoreFocus } from '../../shared/focus.utils';
@Component({
selector: 'c-modal',
@@ -40,7 +43,6 @@ import { ModalDialogComponent } from '../modal-dialog/modal-dialog.component';
'[inert]': 'ariaHidden()',
'[attr.id]': 'id',
'[aria-modal]': 'ariaModal()',
- '[aria-hidden]': 'ariaHidden()',
'[attr.tabindex]': '-1',
'(mousedown)': 'onMouseDownHandler($event)',
'(click)': 'onClickHandler($event)',
@@ -59,7 +61,7 @@ export class ModalComponent implements OnInit, OnDestroy, AfterViewInit {
readonly #backdropService = inject(BackdropService);
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,32 +199,23 @@ export class ModalComponent implements OnInit, OnDestroy, AfterViewInit {
});
});
- readonly #activeElement = signal
(null);
+ #focusBeforeShow: HTMLElement | null = null;
+ #openAction: IModalAction | 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])'
- );
- if (focusable?.length) {
- this.#focusMonitor.focusVia(focusable[0], 'keyboard');
+ if (!this.focusTrap()?.focusTrap.focusInitialElement()) {
+ this.#hostElement.nativeElement.focus();
}
});
} 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();
}
});
});
@@ -234,6 +227,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) => {
@@ -343,6 +337,9 @@ export class ModalComponent implements OnInit, OnDestroy, AfterViewInit {
}
ngOnDestroy(): void {
+ if (this.#trigger || this.#focusBeforeShow) {
+ this.#restoreFocus();
+ }
this.#modalService.toggle({ show: false, modal: this });
this.#afterViewInit.set(false);
this.setBackdrop(false);
@@ -353,16 +350,41 @@ 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.#openAction = action;
+ }
+ this.visible.set(show);
}
} else {
if (this.visible()) {
+ for (const target of this.#focusCandidates()) {
+ if (!action.focusFallback?.includes(target)) {
+ action.focusFallback?.push(target);
+ }
+ }
this.visible.set(false);
}
}
});
}
+ #focusCandidates(): (HTMLElement | null)[] {
+ return [this.#trigger, this.#focusBeforeShow, ...(this.#openAction?.focusFallback ?? [])];
+ }
+
+ #restoreFocus(): void {
+ const candidates = this.#focusCandidates();
+ this.#trigger = null;
+ this.#focusBeforeShow = null;
+ this.#openAction = 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.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 7fd23e60..c7eb7ca1 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;
@@ -298,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() });
@@ -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..643a2768
--- /dev/null
+++ b/projects/coreui-angular/src/lib/shared/focus.utils.spec.ts
@@ -0,0 +1,87 @@
+///
+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 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]);
+ 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..cbbfa467
--- /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; 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
+ */
+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 && !overlay.contains(target)) {
+ target.focus({ preventScroll: true });
+ if (document.activeElement === target) {
+ return true;
+ }
+ }
+ }
+ return false;
+};