From 4389f3dd3c994a90a0bed0ce437750949ea501dc Mon Sep 17 00:00:00 2001 From: Antonio Ledebuhr Date: Thu, 27 Aug 2026 02:17:43 +0200 Subject: [PATCH] Restore gist focus to the list and stop claiming the dialog is modal. SVG nodes are not HTMLElements and sit in an aria-hidden figure, so close returns to the matching card. The gist stays non-modal: no aria-modal, no Tab trap, and destination or outside activation dismisses it. Co-authored-by: Cursor --- e2e/systems-map.e2e.ts | 53 ++++++++ src/app/shared/systems-map/systems-map.html | 6 +- .../shared/systems-map/systems-map.spec.ts | 117 +++++++++++++++- src/app/shared/systems-map/systems-map.ts | 127 ++++++++++++++---- 4 files changed, 273 insertions(+), 30 deletions(-) diff --git a/e2e/systems-map.e2e.ts b/e2e/systems-map.e2e.ts index de7c652..705d3e4 100644 --- a/e2e/systems-map.e2e.ts +++ b/e2e/systems-map.e2e.ts @@ -68,6 +68,35 @@ test.describe('Systems Map', () => { } }); + test('SVG node gist dialog restores focus after Escape and the close button', async ({ + page, + }) => { + const copy = SIGNATURE_COPY.de.systemsMap; + await page.goto(pagePath('services', 'de')); + await page.setViewportSize({ width: 1440, height: 900 }); + + const svgNode = page.locator('.systems-map-figure .systems-map-node').first(); + const listNode = page.locator('.systems-map-cards a').first(); + await expect(page.locator('.systems-map-figure')).toBeVisible(); + + await svgNode.click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await expect(dialog).not.toHaveAttribute('aria-modal'); + + await page.keyboard.press('Escape'); + await expect(dialog).toHaveCount(0); + await expect(listNode).toBeFocused(); + expect(await page.evaluate(() => document.activeElement === document.body)).toBe(false); + + await svgNode.click(); + await expect(dialog).toBeVisible(); + await dialog.getByRole('button', { name: copy.closeLabel }).click(); + await expect(dialog).toHaveCount(0); + await expect(listNode).toBeFocused(); + expect(await page.evaluate(() => document.activeElement === document.body)).toBe(false); + }); + test('plain activation opens the gist dialog without navigating', async ({ page }) => { const copy = SIGNATURE_COPY.de.systemsMap; await page.goto(pagePath('services', 'de')); @@ -80,6 +109,7 @@ test.describe('Systems Map', () => { await first.click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible(); + await expect(dialog).not.toHaveAttribute('aria-modal'); await expect(page).toHaveURL(new RegExp(`${pagePath('services', 'de')}$`)); await expect(dialog.getByRole('link')).toHaveAttribute('href', href ?? ''); await expect(dialog.getByRole('button', { name: copy.closeLabel })).toBeVisible(); @@ -93,6 +123,29 @@ test.describe('Systems Map', () => { await expect(first).toBeFocused(); }); + test('destination and outside activation dismiss the gist and restore focus', async ({ + page, + }) => { + await page.goto(pagePath('services', 'de')); + await page.setViewportSize({ width: 390, height: 844 }); + const first = page.locator('.systems-map-cards a').first(); + + await first.click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('link').click(); + await expect(dialog).toHaveCount(0); + await expect(page).toHaveURL(/#infrastructure-network/); + await expect(first).toBeFocused(); + + await first.click(); + await expect(dialog).toBeVisible(); + await page.locator('.systems-map-heading').click(); + await expect(dialog).toHaveCount(0); + await expect(first).toBeFocused(); + expect(await page.evaluate(() => document.activeElement === document.body)).toBe(false); + }); + test('a modified click follows the node href', async ({ page }) => { await page.goto(pagePath('services', 'de')); const first = page.locator('.systems-map-cards a').first(); diff --git a/src/app/shared/systems-map/systems-map.html b/src/app/shared/systems-map/systems-map.html index 1aa0a1a..cabb1fb 100644 --- a/src/app/shared/systems-map/systems-map.html +++ b/src/app/shared/systems-map/systems-map.html @@ -90,11 +90,11 @@ #gistDialog class="systems-map-dialog" role="dialog" - aria-modal="true" [attr.aria-labelledby]="dialogTitleId" [attr.aria-describedby]="dialogDescId" tabindex="-1" (keydown)="onDialogKeydown($event)" + (focusout)="onDialogFocusOut($event)" >

{{ dialogTitle() }}

@@ -110,7 +110,9 @@

{{ node.gist }}

{{ node.relationship }}

- {{ node.linkLabel }} + {{ + node.linkLabel + }} } diff --git a/src/app/shared/systems-map/systems-map.spec.ts b/src/app/shared/systems-map/systems-map.spec.ts index 70c48d5..57bea4c 100644 --- a/src/app/shared/systems-map/systems-map.spec.ts +++ b/src/app/shared/systems-map/systems-map.spec.ts @@ -107,7 +107,7 @@ describe('SystemsMap', () => { const dialog = compiled.querySelector('[role="dialog"]'); expect(dialog).toBeTruthy(); - expect(dialog?.getAttribute('aria-modal')).toBe('true'); + expect(dialog?.hasAttribute('aria-modal')).toBe(false); expect(dialog?.textContent).toContain(first.label); expect(dialog?.textContent).toContain(first.gist); expect(dialog?.textContent).toContain(first.relationship); @@ -141,6 +141,36 @@ describe('SystemsMap', () => { expect(navigate).not.toHaveBeenCalled(); }); + it('restores focus from an SVG node to the matching list anchor, not an aria-hidden target', async () => { + const fixture = await createFixture(); + const compiled = fixture.nativeElement as HTMLElement; + const svgAnchor = compiled.querySelector('svg a'); + const list = listAnchor(compiled); + + expect(svgAnchor).toBeTruthy(); + expect(svgAnchor instanceof SVGElement).toBe(true); + expect(svgAnchor instanceof HTMLElement).toBe(false); + expect(list.getAttribute('tabindex')).not.toBe('-1'); + expect(list.closest('[aria-hidden="true"]')).toBeNull(); + + svgAnchor?.dispatchEvent(new MouseEvent('click', { button: 0, bubbles: true })); + await flush(fixture); + + const dialog = compiled.querySelector('[role="dialog"]') as HTMLElement; + expect(dialog).toBeTruthy(); + expect(dialog.contains(document.activeElement)).toBe(true); + + compiled.querySelector('.systems-map-dialog-close')?.click(); + await flush(fixture); + + const restored = document.activeElement as HTMLElement | null; + expect(compiled.querySelector('[role="dialog"]')).toBeNull(); + expect(restored).toBe(list); + expect(restored?.isConnected).toBe(true); + expect(restored?.closest('[aria-hidden="true"]')).toBeNull(); + expect(restored).not.toBe(document.body); + }); + it('moves focus into the dialog and restores it to the activating node on close', async () => { const fixture = await createFixture(); const compiled = fixture.nativeElement as HTMLElement; @@ -166,6 +196,91 @@ describe('SystemsMap', () => { expect(copy.closeLabel.length).toBeGreaterThan(0); }); + it('closes the non-modal gist from the destination link, outside activation and Escape', async () => { + const fixture = await createFixture(); + const compiled = fixture.nativeElement as HTMLElement; + const router = TestBed.inject(Router); + vi.spyOn(router, 'navigate').mockResolvedValue(true); + vi.spyOn(router, 'navigateByUrl').mockResolvedValue(true); + + async function openFromList(): Promise { + const anchor = listAnchor(compiled); + anchor.focus(); + anchor.dispatchEvent(new MouseEvent('click', { button: 0, bubbles: true })); + await flush(fixture); + const dialog = compiled.querySelector('[role="dialog"]'); + expect(dialog).toBeTruthy(); + expect(dialog?.hasAttribute('aria-modal')).toBe(false); + return anchor; + } + + const destinationOpener = await openFromList(); + compiled.querySelector('[role="dialog"] a')?.click(); + await flush(fixture); + expect(compiled.querySelector('[role="dialog"]')).toBeNull(); + expect(document.activeElement).toBe(destinationOpener); + + const outsideOpener = await openFromList(); + compiled + .querySelector('.systems-map-heading') + ?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, cancelable: true })); + await flush(fixture); + expect(compiled.querySelector('[role="dialog"]')).toBeNull(); + expect(document.activeElement).toBe(outsideOpener); + + const escapeOpener = await openFromList(); + compiled + .querySelector('.systems-map-dialog-close') + ?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await flush(fixture); + expect(compiled.querySelector('[role="dialog"]')).toBeNull(); + expect(document.activeElement).toBe(escapeOpener); + }); + + it('lets Tab leave the gist and closes it without cycling focus', async () => { + const fixture = await createFixture(); + const compiled = fixture.nativeElement as HTMLElement; + const opener = listAnchor(compiled); + const next = listAnchor(compiled, 1); + opener.focus(); + opener.dispatchEvent(new MouseEvent('click', { button: 0, bubbles: true })); + await flush(fixture); + + const dialog = compiled.querySelector('[role="dialog"]') as HTMLElement; + expect(dialog).toBeTruthy(); + + const tab = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }); + const prevent = vi.spyOn(tab, 'preventDefault'); + dialog.dispatchEvent(tab); + await flush(fixture); + expect(prevent).not.toHaveBeenCalled(); + expect(compiled.querySelector('[role="dialog"]')).toBeTruthy(); + + dialog.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: next })); + await flush(fixture); + expect(compiled.querySelector('[role="dialog"]')).toBeNull(); + expect(document.activeElement).not.toBe(opener); + }); + + it('removes the document dismiss listeners on destroy', async () => { + const add = vi.spyOn(document, 'addEventListener'); + const fixture = await createFixture(); + const compiled = fixture.nativeElement as HTMLElement; + listAnchor(compiled).dispatchEvent(new MouseEvent('click', { button: 0, bubbles: true })); + await flush(fixture); + + const pointer = add.mock.calls.find((call) => call[0] === 'pointerdown'); + const keydown = add.mock.calls.find((call) => call[0] === 'keydown'); + expect(pointer).toBeTruthy(); + expect(keydown).toBeTruthy(); + + const remove = vi.spyOn(document, 'removeEventListener'); + fixture.destroy(); + + expect(remove).toHaveBeenCalledWith('pointerdown', pointer?.[1], true); + expect(remove).toHaveBeenCalledWith('keydown', keydown?.[1]); + }); + it('keeps instance-scoped ids unique when two maps are rendered', async () => { await configure(); const first = TestBed.createComponent(SystemsMap); diff --git a/src/app/shared/systems-map/systems-map.ts b/src/app/shared/systems-map/systems-map.ts index 2402e07..ec97b7b 100644 --- a/src/app/shared/systems-map/systems-map.ts +++ b/src/app/shared/systems-map/systems-map.ts @@ -4,6 +4,7 @@ import { ChangeDetectionStrategy, Component, computed, + DestroyRef, ElementRef, inject, Injector, @@ -75,10 +76,13 @@ export class SystemsMap { private readonly injector = inject(Injector); private readonly navigation = inject(NavigationService); private readonly localeService = inject(LocaleService); + private readonly host = inject>(ElementRef); + private readonly destroyRef = inject(DestroyRef); private readonly isBrowser = isBrowserPlatform(); private readonly instanceId = systemsMapInstanceId++; private readonly dialogRef = viewChild>('gistDialog'); private opener: HTMLElement | null = null; + private dismissListening = false; protected readonly headingId = `systems-map-heading-${this.instanceId}`; protected readonly svgTitleId = `systems-map-svg-title-${this.instanceId}`; @@ -145,13 +149,17 @@ export class SystemsMap { return node ? this.copy().dialogTitlePattern.replace('{node}', node.label) : ''; }); + constructor() { + this.destroyRef.onDestroy(() => this.unbindDismissListeners()); + } + protected onNodeActivate(event: MouseEvent, node: SystemsMapNodeView): void { if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) { return; } event.preventDefault(); - this.openDialog(node.id, event.currentTarget as HTMLElement); + this.openDialog(node.id); } protected onNodeKeydown(event: KeyboardEvent, node: SystemsMapNodeView): void { @@ -160,7 +168,7 @@ export class SystemsMap { } event.preventDefault(); - this.openDialog(node.id, event.currentTarget as HTMLElement); + this.openDialog(node.id); } protected onNodeEnter(id: SystemsMapNodeId): void { @@ -174,17 +182,19 @@ export class SystemsMap { } protected closeDialog(): void { - this.openNodeId.set(null); const opener = this.opener; + const wasOpen = this.openNodeId() !== null; + this.unbindDismissListeners(); + this.openNodeId.set(null); this.opener = null; - if (!this.isBrowser || !(opener instanceof HTMLElement)) { + if (!wasOpen || !this.isBrowser || !this.isRestorableOpener(opener)) { return; } afterNextRender( () => { - if (opener.isConnected) { + if (this.isRestorableOpener(opener)) { opener.focus(); } }, @@ -192,39 +202,32 @@ export class SystemsMap { ); } + /** + * Escape dismisses the non-modal gist. Tab is not trapped: a cycle + * would claim modal behaviour the dialog does not have. Leaving via + * Tab closes the gist and leaves focus where it moved. + */ protected onDialogKeydown(event: KeyboardEvent): void { if (event.key === 'Escape') { event.preventDefault(); this.closeDialog(); - return; } + } - if (event.key !== 'Tab') { + protected onDialogFocusOut(event: FocusEvent): void { + if (!this.openNodeId()) { return; } const dialog = this.dialogRef()?.nativeElement; - if (!dialog) { + const next = event.relatedTarget; + if (!(next instanceof Node) || dialog?.contains(next)) { return; } - const focusable = this.focusableIn(dialog); - if (focusable.length === 0) { - event.preventDefault(); - return; - } - - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - const active = this.document.activeElement; - - if (event.shiftKey && active === first) { - event.preventDefault(); - last.focus(); - } else if (!event.shiftKey && active === last) { - event.preventDefault(); - first.focus(); - } + this.unbindDismissListeners(); + this.openNodeId.set(null); + this.opener = null; } protected isConnectedNode(id: SystemsMapNodeId): boolean { @@ -247,9 +250,10 @@ export class SystemsMap { return !!active && edge.from !== active && edge.to !== active; } - private openDialog(id: SystemsMapNodeId, opener: HTMLElement): void { - this.opener = opener; + private openDialog(id: SystemsMapNodeId): void { + this.opener = this.listAnchorFor(id); this.openNodeId.set(id); + this.bindDismissListeners(); afterNextRender( () => { @@ -261,6 +265,75 @@ export class SystemsMap { ); } + /** + * Restore to the list-fallback anchor for this node. The SVG figure is + * aria-hidden and its nodes are tabindex="-1"; focusing that subtree + * would drop the user into hidden content. The card list is the single + * intended keyboard target and stays rendered at every viewport. + */ + private listAnchorFor(id: SystemsMapNodeId): HTMLElement | null { + const index = this.nodes().findIndex((node) => node.id === id); + if (index < 0) { + return null; + } + + return ( + this.host.nativeElement + .querySelectorAll('.systems-map-cards a') + .item(index) ?? null + ); + } + + private isRestorableOpener(opener: HTMLElement | null): opener is HTMLElement { + return ( + !!opener && + opener.isConnected && + opener.closest('[aria-hidden="true"]') === null && + opener.getAttribute('tabindex') !== '-1' + ); + } + + private bindDismissListeners(): void { + if (!this.isBrowser || this.dismissListening) { + return; + } + + this.document.addEventListener('pointerdown', this.onDocumentPointerDown, true); + this.document.addEventListener('keydown', this.onDocumentKeydown); + this.dismissListening = true; + } + + private unbindDismissListeners(): void { + if (!this.dismissListening) { + return; + } + + this.document.removeEventListener('pointerdown', this.onDocumentPointerDown, true); + this.document.removeEventListener('keydown', this.onDocumentKeydown); + this.dismissListening = false; + } + + private readonly onDocumentPointerDown = (event: Event): void => { + if (!this.openNodeId()) { + return; + } + + const dialog = this.dialogRef()?.nativeElement; + const target = event.target; + if (!(target instanceof Node) || !dialog || dialog.contains(target)) { + return; + } + + this.closeDialog(); + }; + + private readonly onDocumentKeydown = (event: KeyboardEvent): void => { + if (event.key === 'Escape' && this.openNodeId()) { + event.preventDefault(); + this.closeDialog(); + } + }; + private focusableIn(root: HTMLElement | undefined): HTMLElement[] { if (!root) { return [];