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:
@@ -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();
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
<div class="systems-map-dialog-bar cluster">
|
||||
<h3 [id]="dialogTitleId">{{ dialogTitle() }}</h3>
|
||||
@@ -110,7 +110,9 @@
|
||||
</div>
|
||||
<p class="systems-map-dialog-gist" [id]="dialogDescId">{{ node.gist }}</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>
|
||||
}
|
||||
</section>
|
||||
|
||||
@@ -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<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 () => {
|
||||
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<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 () => {
|
||||
await configure();
|
||||
const first = TestBed.createComponent(SystemsMap);
|
||||
|
||||
@@ -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<HTMLElement>>(ElementRef);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly isBrowser = isBrowserPlatform();
|
||||
private readonly instanceId = systemsMapInstanceId++;
|
||||
private readonly dialogRef = viewChild<ElementRef<HTMLElement>>('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<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[] {
|
||||
if (!root) {
|
||||
return [];
|
||||
|
||||
Reference in New Issue
Block a user