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 [];