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 }) => {
|
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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 [];
|
||||||
|
|||||||
Reference in New Issue
Block a user