diff --git a/projects/coreui-angular/src/lib/modal/modal.service.ts b/projects/coreui-angular/src/lib/modal/modal.service.ts index beec4cfd..2742c57d 100644 --- a/projects/coreui-angular/src/lib/modal/modal.service.ts +++ b/projects/coreui-angular/src/lib/modal/modal.service.ts @@ -16,8 +16,15 @@ export interface IModalAction { export class ModalService { readonly #modalState = new Subject(); readonly modalState$ = this.#modalState.asObservable(); + readonly #opened = new Subject<{ modal: ModalComponent; focusFallback: (HTMLElement | null)[] }>(); + /** Emits once a modal has opened through `toggle()`; the other visible modals close on it. */ + readonly opened$ = this.#opened.asObservable(); toggle(action: IModalAction): void { this.#modalState.next({ ...action, focusFallback: [...(action.focusFallback ?? [])] }); } + + announceOpened(modal: ModalComponent, focusFallback: (HTMLElement | null)[]): void { + this.#opened.next({ modal, 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 b981178d..ecbedeb9 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 @@ -6,6 +6,7 @@ import { By } from '@angular/platform-browser'; import { ModalComponent } from './modal.component'; import { ModalService } from '../modal.service'; +import { ModalToggleDirective } from '../modal-dismiss/modal-toggle.directive'; @Component({ template: '' @@ -14,6 +15,17 @@ class TestComponent { readonly outlet = viewChild.required('outlet', { read: ViewContainerRef }); } +@Component({ + template: ` + + + `, + imports: [ModalComponent, ModalToggleDirective] +}) +class OptionalIdHostComponent { + id: string | undefined = undefined; +} + describe('ModalComponent', () => { let component: ModalComponent; let componentRef: ComponentRef; @@ -240,6 +252,7 @@ describe('ModalComponent', () => { describe('focus return', () => { let inside: HTMLButtonElement; let input: HTMLInputElement; + let other: ComponentRef | undefined; let service: ModalService; let trigger: HTMLButtonElement; @@ -249,6 +262,14 @@ describe('ModalComponent', () => { await vi.runAllTimersAsync(); }; + const createOther = async () => { + other = fixture.componentInstance.outlet().createComponent(ModalComponent); + other.setInput('id', 'other-modal'); + fixture.detectChanges(); + await vi.runAllTimersAsync(); + return other; + }; + beforeEach(() => { service = TestBed.inject(ModalService); inside = document.createElement('button'); @@ -258,9 +279,16 @@ describe('ModalComponent', () => { document.body.append(input, trigger); }); - afterEach(() => { + afterEach(async () => { input.remove(); trigger.remove(); + if (other && !other.hostView.destroyed) { + other.instance.visible.set(false); + fixture.detectChanges(); + await vi.runAllTimersAsync(); + other.destroy(); + } + other = undefined; }); it('should return focus to the toggle that opened it', async () => { @@ -359,15 +387,21 @@ describe('ModalComponent', () => { expect(element.getAttribute('role')).toBe('dialog'); }); - it('should hand its focus candidates to the action that closes it for another modal', async () => { + it('should hand its focus candidates to the modal that opens instead', async () => { + const opened = await createOther(); 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); + input.focus(); + + service.toggle({ show: true, id: 'other-modal' }); + fixture.detectChanges(); + await vi.runAllTimersAsync(); + expect(component.visible()).toBe(false); + + input.remove(); + opened.instance.visible.set(false); fixture.detectChanges(); await vi.runAllTimersAsync(); + expect(document.activeElement).toBe(trigger); }); it('should return focus to a fallback handed over by the action that opened it', async () => { @@ -403,7 +437,22 @@ describe('ModalComponent', () => { stale.remove(); }); + it('should pass on the fallback it was opened with', async () => { + await createOther(); + service.toggle({ show: true, modal: component, focusFallback: [input] }); + fixture.detectChanges(); + await vi.runAllTimersAsync(); + let received: { focusFallback?: (HTMLElement | null)[] } = {}; + service.modalState$.subscribe((action) => (received = action)); + + service.toggle({ show: true, id: 'other-modal' }); + expect(received.focusFallback).toContain(input); + fixture.detectChanges(); + await vi.runAllTimersAsync(); + }); + it('should hand over each focus candidate once', async () => { + await createOther(); await toggle(true, trigger); inside.focus(); let received: { focusFallback?: (HTMLElement | null)[] } = {}; @@ -423,6 +472,179 @@ describe('ModalComponent', () => { }); }); + describe('with another modal', () => { + let other: ComponentRef; + let service: ModalService; + + const settle = async () => { + fixture.detectChanges(); + await vi.runAllTimersAsync(); + }; + + beforeEach(async () => { + service = TestBed.inject(ModalService); + other = fixture.componentInstance.outlet().createComponent(ModalComponent); + await settle(); + }); + + afterEach(async () => { + if (!other.hostView.destroyed) { + other.instance.visible.set(false); + await settle(); + other.destroy(); + } + }); + + it.each([ + ['set ids', 'first', 'second'], + ['generated ids', null, null], + ['ids bound to undefined', undefined, undefined] + ])('should stay open when another, closed modal is destroyed (%s)', async (_, id, otherId) => { + if (id !== null && otherId !== null) { + componentRef.setInput('id', id); + other.setInput('id', otherId); + } + component.visible.set(true); + await settle(); + + other.destroy(); + await settle(); + expect(component.visible()).toBe(true); + }); + + it('should stay open when an action closes another modal', async () => { + component.visible.set(true); + await settle(); + + service.toggle({ show: false, modal: other.instance }); + await settle(); + expect(component.visible()).toBe(true); + }); + + it('should close when another modal opens', async () => { + component.visible.set(true); + await settle(); + + service.toggle({ show: true, modal: other.instance }); + await settle(); + expect(component.visible()).toBe(false); + expect(other.instance.visible()).toBe(true); + }); + + it('should close on a toggle without an id', async () => { + component.visible.set(true); + await settle(); + + service.toggle({ show: 'toggle', id: '' }); + await settle(); + expect(component.visible()).toBe(false); + }); + + it('should close on a close action without a target', async () => { + component.visible.set(true); + await settle(); + + service.toggle({ show: false }); + await settle(); + expect(component.visible()).toBe(false); + }); + + it('should stay open when a toggle closes another visible modal', async () => { + component.visible.set(true); + other.instance.visible.set(true); + await settle(); + + service.toggle({ show: 'toggle', modal: other.instance }); + await settle(); + expect(other.instance.visible()).toBe(false); + expect(component.visible()).toBe(true); + }); + + it('should stay open when an action aims at a modal that does not exist', async () => { + component.visible.set(true); + await settle(); + + service.toggle({ show: true, id: 'missing-modal' }); + await settle(); + expect(component.visible()).toBe(true); + }); + + it('should open a modal without an id through its generated id only', async () => { + service.toggle({ show: 'toggle', id: other.instance.id }); + await settle(); + expect(other.instance.id).toBeTruthy(); + expect(other.instance.visible()).toBe(true); + expect(component.visible()).toBe(false); + }); + + it('should render an id the author set', async () => { + other.setInput('id', 'set-id'); + await settle(); + expect(other.location.nativeElement.id).toBe('set-id'); + }); + + it('should give every modal its own generated id', async () => { + expect(component.id).not.toBe(other.instance.id); + + service.toggle({ show: 'toggle', id: component.id }); + await settle(); + expect(component.visible()).toBe(true); + expect(other.instance.visible()).toBe(false); + }); + + it('should close when another modal opens through a toggle', async () => { + component.visible.set(true); + await settle(); + + service.toggle({ show: 'toggle', id: other.instance.id }); + await settle(); + expect(component.visible()).toBe(false); + expect(other.instance.visible()).toBe(true); + }); + + it('should close every visible modal on an open action without a target', async () => { + component.visible.set(true); + await settle(); + + service.toggle({ show: true }); + await settle(); + expect(component.visible()).toBe(false); + expect(other.instance.visible()).toBe(false); + }); + + it('should announce an opening once the modal is visible', async () => { + const seen: boolean[] = []; + service.opened$.subscribe(({ modal }) => seen.push(modal.visible())); + + service.toggle({ show: true, modal: other.instance }); + await settle(); + expect(seen).toEqual([true]); + }); + + it('should not render a generated id', () => { + expect(other.instance.id).toBeTruthy(); + expect(other.location.nativeElement.hasAttribute('id')).toBe(false); + }); + }); + + describe('with an optional id binding', () => { + it('should open through cModalToggle bound to its id property', async () => { + const fixture = TestBed.createComponent(OptionalIdHostComponent); + fixture.detectChanges(); + await vi.runAllTimersAsync(); + const modal = fixture.debugElement.query(By.directive(ModalComponent)).componentInstance as ModalComponent; + + (fixture.nativeElement.querySelector('button') as HTMLButtonElement).click(); + fixture.detectChanges(); + await vi.runAllTimersAsync(); + expect(modal.visible()).toBe(true); + + modal.visible.set(false); + fixture.detectChanges(); + await vi.runAllTimersAsync(); + }); + }); + describe('with portal', () => { let container: HTMLDivElement; let originalParent: HTMLElement | null; 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 4473eb99..4a5c2845 100644 --- a/projects/coreui-angular/src/lib/modal/modal/modal.component.ts +++ b/projects/coreui-angular/src/lib/modal/modal/modal.component.ts @@ -29,6 +29,7 @@ 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'; +import { UIDService } from '../../services/uid.service'; @Component({ selector: 'c-modal', @@ -40,7 +41,7 @@ import { restoreFocus } from '../../shared/focus.utils'; '[class]': 'hostClasses()', '[attr.role]': 'visible() ? role() : null', '[inert]': 'ariaHidden()', - '[attr.id]': 'id', + '[attr.id]': 'attrId()', '[aria-modal]': 'ariaModal()', '[attr.tabindex]': '-1', '(mousedown)': 'onMouseDownHandler($event)', @@ -57,6 +58,7 @@ export class ModalComponent implements OnInit, OnDestroy, AfterViewInit { readonly #destroyRef = inject(DestroyRef); readonly #platformId = inject(PLATFORM_ID); + readonly #generatedId = inject(UIDService).getUID('modal'); /** * Align the modal in the center or top of the screen. @@ -95,13 +97,13 @@ export class ModalComponent implements OnInit, OnDestroy, AfterViewInit { readonly keyboard = input(true, { transform: booleanAttribute }); /** - * HTML id attribute, required for programmatic visibility change. + * HTML id attribute, used to toggle the modal by id. Without it, the `id` property holds a generated id that is not rendered. * @returns string */ readonly attrId = input(undefined, { alias: 'id' }); get id() { - return this.attrId(); + return this.attrId() || this.#generatedId; } /** @@ -343,27 +345,40 @@ export class ModalComponent implements OnInit, OnDestroy, AfterViewInit { private stateToggleSubscribe(): void { this.#modalService.modalState$.pipe(takeUntilDestroyed(this.#destroyRef)).subscribe((action) => { - if (this === action.modal || this.id === action.id) { + if (!action.modal && !action.id) { + this.#closeFor(action.focusFallback); + } else if (this === action.modal || this.id === action.id) { if ('show' in action) { 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(true); + this.#modalService.announceOpened(this, action.focusFallback); + } else { + this.visible.set(show); } - 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); } } }); + this.#modalService.opened$.pipe(takeUntilDestroyed(this.#destroyRef)).subscribe(({ modal, focusFallback }) => { + if (modal !== this) { + this.#closeFor(focusFallback); + } + }); + } + + #closeFor(focusFallback: (HTMLElement | null)[]): void { + if (!this.visible()) { + return; + } + for (const target of this.#focusCandidates()) { + if (!focusFallback.includes(target)) { + focusFallback.push(target); + } + } + this.visible.set(false); } #focusCandidates(): (HTMLElement | null)[] {