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 <cursoragent@cursor.com>
This commit is contained in:
2026-08-27 02:17:43 +02:00
parent 334b30145e
commit 4389f3dd3c
4 changed files with 273 additions and 30 deletions

View File

@@ -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 }) => { test('plain activation opens the gist dialog without navigating', async ({ page }) => {
const copy = SIGNATURE_COPY.de.systemsMap; const copy = SIGNATURE_COPY.de.systemsMap;
await page.goto(pagePath('services', 'de')); await page.goto(pagePath('services', 'de'));
@@ -80,6 +109,7 @@ test.describe('Systems Map', () => {
await first.click(); await first.click();
const dialog = page.getByRole('dialog'); const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible(); await expect(dialog).toBeVisible();
await expect(dialog).not.toHaveAttribute('aria-modal');
await expect(page).toHaveURL(new RegExp(`${pagePath('services', 'de')}$`)); await expect(page).toHaveURL(new RegExp(`${pagePath('services', 'de')}$`));
await expect(dialog.getByRole('link')).toHaveAttribute('href', href ?? ''); await expect(dialog.getByRole('link')).toHaveAttribute('href', href ?? '');
await expect(dialog.getByRole('button', { name: copy.closeLabel })).toBeVisible(); await expect(dialog.getByRole('button', { name: copy.closeLabel })).toBeVisible();
@@ -93,6 +123,29 @@ test.describe('Systems Map', () => {
await expect(first).toBeFocused(); 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 }) => { test('a modified click follows the node href', async ({ page }) => {
await page.goto(pagePath('services', 'de')); await page.goto(pagePath('services', 'de'));
const first = page.locator('.systems-map-cards a').first(); const first = page.locator('.systems-map-cards a').first();

View File

@@ -90,11 +90,11 @@
#gistDialog #gistDialog
class="systems-map-dialog" class="systems-map-dialog"
role="dialog" role="dialog"
aria-modal="true"
[attr.aria-labelledby]="dialogTitleId" [attr.aria-labelledby]="dialogTitleId"
[attr.aria-describedby]="dialogDescId" [attr.aria-describedby]="dialogDescId"
tabindex="-1" tabindex="-1"
(keydown)="onDialogKeydown($event)" (keydown)="onDialogKeydown($event)"
(focusout)="onDialogFocusOut($event)"
> >
<div class="systems-map-dialog-bar cluster"> <div class="systems-map-dialog-bar cluster">
<h3 [id]="dialogTitleId">{{ dialogTitle() }}</h3> <h3 [id]="dialogTitleId">{{ dialogTitle() }}</h3>
@@ -110,7 +110,9 @@
</div> </div>
<p class="systems-map-dialog-gist" [id]="dialogDescId">{{ node.gist }}</p> <p class="systems-map-dialog-gist" [id]="dialogDescId">{{ node.gist }}</p>
<p class="systems-map-dialog-relation">{{ node.relationship }}</p> <p class="systems-map-dialog-relation">{{ node.relationship }}</p>
<a [routerLink]="node.link" [fragment]="node.fragment">{{ node.linkLabel }}</a> <a [routerLink]="node.link" [fragment]="node.fragment" (click)="closeDialog()">{{
node.linkLabel
}}</a>
</div> </div>
} }
</section> </section>

View File

@@ -107,7 +107,7 @@ describe('SystemsMap', () => {
const dialog = compiled.querySelector('[role="dialog"]'); const dialog = compiled.querySelector('[role="dialog"]');
expect(dialog).toBeTruthy(); 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.label);
expect(dialog?.textContent).toContain(first.gist); expect(dialog?.textContent).toContain(first.gist);
expect(dialog?.textContent).toContain(first.relationship); expect(dialog?.textContent).toContain(first.relationship);
@@ -141,6 +141,36 @@ describe('SystemsMap', () => {
expect(navigate).not.toHaveBeenCalled(); 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<HTMLButtonElement>('.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 () => { it('moves focus into the dialog and restores it to the activating node on close', async () => {
const fixture = await createFixture(); const fixture = await createFixture();
const compiled = fixture.nativeElement as HTMLElement; const compiled = fixture.nativeElement as HTMLElement;
@@ -166,6 +196,91 @@ describe('SystemsMap', () => {
expect(copy.closeLabel.length).toBeGreaterThan(0); 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<HTMLAnchorElement> {
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<HTMLAnchorElement>('[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<HTMLButtonElement>('.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 () => { it('keeps instance-scoped ids unique when two maps are rendered', async () => {
await configure(); await configure();
const first = TestBed.createComponent(SystemsMap); const first = TestBed.createComponent(SystemsMap);

View File

@@ -4,6 +4,7 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
computed, computed,
DestroyRef,
ElementRef, ElementRef,
inject, inject,
Injector, Injector,
@@ -75,10 +76,13 @@ export class SystemsMap {
private readonly injector = inject(Injector); private readonly injector = inject(Injector);
private readonly navigation = inject(NavigationService); private readonly navigation = inject(NavigationService);
private readonly localeService = inject(LocaleService); private readonly localeService = inject(LocaleService);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly destroyRef = inject(DestroyRef);
private readonly isBrowser = isBrowserPlatform(); private readonly isBrowser = isBrowserPlatform();
private readonly instanceId = systemsMapInstanceId++; private readonly instanceId = systemsMapInstanceId++;
private readonly dialogRef = viewChild<ElementRef<HTMLElement>>('gistDialog'); private readonly dialogRef = viewChild<ElementRef<HTMLElement>>('gistDialog');
private opener: HTMLElement | null = null; private opener: HTMLElement | null = null;
private dismissListening = false;
protected readonly headingId = `systems-map-heading-${this.instanceId}`; protected readonly headingId = `systems-map-heading-${this.instanceId}`;
protected readonly svgTitleId = `systems-map-svg-title-${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) : ''; return node ? this.copy().dialogTitlePattern.replace('{node}', node.label) : '';
}); });
constructor() {
this.destroyRef.onDestroy(() => this.unbindDismissListeners());
}
protected onNodeActivate(event: MouseEvent, node: SystemsMapNodeView): void { protected onNodeActivate(event: MouseEvent, node: SystemsMapNodeView): void {
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) { if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
return; return;
} }
event.preventDefault(); event.preventDefault();
this.openDialog(node.id, event.currentTarget as HTMLElement); this.openDialog(node.id);
} }
protected onNodeKeydown(event: KeyboardEvent, node: SystemsMapNodeView): void { protected onNodeKeydown(event: KeyboardEvent, node: SystemsMapNodeView): void {
@@ -160,7 +168,7 @@ export class SystemsMap {
} }
event.preventDefault(); event.preventDefault();
this.openDialog(node.id, event.currentTarget as HTMLElement); this.openDialog(node.id);
} }
protected onNodeEnter(id: SystemsMapNodeId): void { protected onNodeEnter(id: SystemsMapNodeId): void {
@@ -174,17 +182,19 @@ export class SystemsMap {
} }
protected closeDialog(): void { protected closeDialog(): void {
this.openNodeId.set(null);
const opener = this.opener; const opener = this.opener;
const wasOpen = this.openNodeId() !== null;
this.unbindDismissListeners();
this.openNodeId.set(null);
this.opener = null; this.opener = null;
if (!this.isBrowser || !(opener instanceof HTMLElement)) { if (!wasOpen || !this.isBrowser || !this.isRestorableOpener(opener)) {
return; return;
} }
afterNextRender( afterNextRender(
() => { () => {
if (opener.isConnected) { if (this.isRestorableOpener(opener)) {
opener.focus(); 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 { protected onDialogKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') { if (event.key === 'Escape') {
event.preventDefault(); event.preventDefault();
this.closeDialog(); this.closeDialog();
return;
} }
}
if (event.key !== 'Tab') { protected onDialogFocusOut(event: FocusEvent): void {
if (!this.openNodeId()) {
return; return;
} }
const dialog = this.dialogRef()?.nativeElement; const dialog = this.dialogRef()?.nativeElement;
if (!dialog) { const next = event.relatedTarget;
if (!(next instanceof Node) || dialog?.contains(next)) {
return; return;
} }
const focusable = this.focusableIn(dialog); this.unbindDismissListeners();
if (focusable.length === 0) { this.openNodeId.set(null);
event.preventDefault(); this.opener = null;
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();
}
} }
protected isConnectedNode(id: SystemsMapNodeId): boolean { protected isConnectedNode(id: SystemsMapNodeId): boolean {
@@ -247,9 +250,10 @@ export class SystemsMap {
return !!active && edge.from !== active && edge.to !== active; return !!active && edge.from !== active && edge.to !== active;
} }
private openDialog(id: SystemsMapNodeId, opener: HTMLElement): void { private openDialog(id: SystemsMapNodeId): void {
this.opener = opener; this.opener = this.listAnchorFor(id);
this.openNodeId.set(id); this.openNodeId.set(id);
this.bindDismissListeners();
afterNextRender( 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<HTMLAnchorElement>('.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[] { private focusableIn(root: HTMLElement | undefined): HTMLElement[] {
if (!root) { if (!root) {
return []; return [];