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.spec.ts b/projects/coreui-angular/src/lib/modal/modal.service.spec.ts index 0ad21ad7..957317ff 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,27 @@ describe('ModalService', () => { it('should be created', () => { expect(service).toBeTruthy(); }); + + 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, 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 12ced01e..beec4cfd 100644 --- a/projects/coreui-angular/src/lib/modal/modal.service.ts +++ b/projects/coreui-angular/src/lib/modal/modal.service.ts @@ -5,7 +5,9 @@ import { ModalComponent } from './modal/modal.component'; export interface IModalAction { show?: boolean | 'toggle'; modal?: ModalComponent; + focusFallback?: (HTMLElement | null)[]; id?: string; + trigger?: HTMLElement; } @Injectable({ @@ -16,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.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..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; +};