Open the systems-map gist dialog and turn the dock into a default-open TTY.
Plain node activation no longer navigates, case cards stay quiet evidence rows, and the terminal keeps one top-to-bottom scrollport. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,12 +1,25 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, input, signal } from '@angular/core';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import {
|
||||
afterNextRender,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
inject,
|
||||
Injector,
|
||||
input,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { SIGNATURE_COPY } from '../../core/content/signature-copy';
|
||||
import { type AppLocale } from '../../core/i18n/locale';
|
||||
import { LocaleService } from '../../core/i18n/locale.service';
|
||||
import { NavigationService } from '../../core/navigation/navigation.service';
|
||||
import { routePath } from '../../core/routing/route-paths';
|
||||
import { isBrowserPlatform } from '../../core/platform/browser';
|
||||
import {
|
||||
connectedNodeIds,
|
||||
nodeHref,
|
||||
SYSTEMS_MAP_EDGES,
|
||||
SYSTEMS_MAP_NODE_KINDS,
|
||||
SYSTEMS_MAP_NODES,
|
||||
@@ -20,15 +33,18 @@ export interface SystemsMapNodeView {
|
||||
readonly id: SystemsMapNodeId;
|
||||
readonly kind: SystemsMapNode['kind'];
|
||||
readonly routeId: SystemsMapNode['routeId'];
|
||||
readonly fragment: string;
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly label: string;
|
||||
readonly labelLines: readonly string[];
|
||||
readonly summary: string;
|
||||
readonly gist: string;
|
||||
readonly href: string;
|
||||
readonly link: unknown[];
|
||||
readonly relationship: string;
|
||||
readonly accessibleName: string;
|
||||
readonly linkLabel: string;
|
||||
readonly shapeWidth: number;
|
||||
readonly shapeHeight: number;
|
||||
readonly textStartDy: number;
|
||||
@@ -55,18 +71,25 @@ export class SystemsMap {
|
||||
readonly intro = input('');
|
||||
readonly headingLevel = input<2 | 3>(2);
|
||||
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly navigation = inject(NavigationService);
|
||||
private readonly localeService = inject(LocaleService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly isBrowser = isBrowserPlatform();
|
||||
private readonly instanceId = systemsMapInstanceId++;
|
||||
private readonly dialogRef = viewChild<ElementRef<HTMLElement>>('gistDialog');
|
||||
private opener: HTMLElement | null = null;
|
||||
|
||||
protected readonly headingId = `systems-map-heading-${this.instanceId}`;
|
||||
protected readonly svgTitleId = `systems-map-svg-title-${this.instanceId}`;
|
||||
protected readonly svgDescId = `systems-map-svg-desc-${this.instanceId}`;
|
||||
protected readonly listHeadingId = `systems-map-list-${this.instanceId}`;
|
||||
protected readonly legendHeadingId = `systems-map-legend-${this.instanceId}`;
|
||||
protected readonly dialogTitleId = `systems-map-dialog-title-${this.instanceId}`;
|
||||
protected readonly dialogDescId = `systems-map-dialog-desc-${this.instanceId}`;
|
||||
|
||||
protected readonly activeNodeId = signal<SystemsMapNodeId | null>(null);
|
||||
protected readonly openNodeId = signal<SystemsMapNodeId | null>(null);
|
||||
|
||||
protected readonly copy = computed(() => SIGNATURE_COPY[this.localeService.locale()].systemsMap);
|
||||
|
||||
@@ -82,9 +105,7 @@ export class SystemsMap {
|
||||
|
||||
protected readonly nodes = computed(() => {
|
||||
const locale = this.localeService.locale();
|
||||
const copy = this.copy();
|
||||
|
||||
return SYSTEMS_MAP_NODES.map((node) => this.toNodeView(node, locale, copy.relationshipLabel));
|
||||
return SYSTEMS_MAP_NODES.map((node) => this.toNodeView(node, locale));
|
||||
});
|
||||
|
||||
protected readonly edges = computed((): readonly SystemsMapEdgeView[] => {
|
||||
@@ -114,15 +135,14 @@ export class SystemsMap {
|
||||
}));
|
||||
});
|
||||
|
||||
protected readonly activeReadout = computed(() => {
|
||||
const activeId = this.activeNodeId();
|
||||
protected readonly openNode = computed(() => {
|
||||
const id = this.openNodeId();
|
||||
return id ? (this.nodes().find((node) => node.id === id) ?? null) : null;
|
||||
});
|
||||
|
||||
if (!activeId) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const node = this.nodes().find((entry) => entry.id === activeId);
|
||||
return node ? `${node.label}: ${node.relationship}` : '';
|
||||
protected readonly dialogTitle = computed(() => {
|
||||
const node = this.openNode();
|
||||
return node ? this.copy().dialogTitlePattern.replace('{node}', node.label) : '';
|
||||
});
|
||||
|
||||
protected onNodeActivate(event: MouseEvent, node: SystemsMapNodeView): void {
|
||||
@@ -131,7 +151,16 @@ export class SystemsMap {
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
void this.router.navigate(this.navigation.link(node.routeId));
|
||||
this.openDialog(node.id, event.currentTarget as HTMLElement);
|
||||
}
|
||||
|
||||
protected onNodeKeydown(event: KeyboardEvent, node: SystemsMapNodeView): void {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
this.openDialog(node.id, event.currentTarget as HTMLElement);
|
||||
}
|
||||
|
||||
protected onNodeEnter(id: SystemsMapNodeId): void {
|
||||
@@ -144,78 +173,136 @@ export class SystemsMap {
|
||||
}
|
||||
}
|
||||
|
||||
protected isConnectedNode(id: SystemsMapNodeId): boolean {
|
||||
const active = this.activeNodeId();
|
||||
protected closeDialog(): void {
|
||||
this.openNodeId.set(null);
|
||||
const opener = this.opener;
|
||||
this.opener = null;
|
||||
|
||||
if (!active) {
|
||||
return false;
|
||||
if (!this.isBrowser || !(opener instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return active === id || this.neighborSet(active).has(id);
|
||||
afterNextRender(
|
||||
() => {
|
||||
if (opener.isConnected) {
|
||||
opener.focus();
|
||||
}
|
||||
},
|
||||
{ injector: this.injector },
|
||||
);
|
||||
}
|
||||
|
||||
protected onDialogKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.closeDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== 'Tab') {
|
||||
return;
|
||||
}
|
||||
|
||||
const dialog = this.dialogRef()?.nativeElement;
|
||||
if (!dialog) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
protected isConnectedNode(id: SystemsMapNodeId): boolean {
|
||||
const active = this.activeNodeId();
|
||||
return !!active && (active === id || this.neighborSet(active).has(id));
|
||||
}
|
||||
|
||||
protected isDimmedNode(id: SystemsMapNodeId): boolean {
|
||||
const active = this.activeNodeId();
|
||||
|
||||
if (!active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return active !== id && !this.neighborSet(active).has(id);
|
||||
return !!active && active !== id && !this.neighborSet(active).has(id);
|
||||
}
|
||||
|
||||
protected isConnectedEdge(edge: SystemsMapEdgeView): boolean {
|
||||
const active = this.activeNodeId();
|
||||
|
||||
if (!active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return edge.from === active || edge.to === active;
|
||||
return !!active && (edge.from === active || edge.to === active);
|
||||
}
|
||||
|
||||
protected isDimmedEdge(edge: SystemsMapEdgeView): boolean {
|
||||
const active = this.activeNodeId();
|
||||
|
||||
if (!active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return edge.from !== active && edge.to !== active;
|
||||
return !!active && edge.from !== active && edge.to !== active;
|
||||
}
|
||||
|
||||
private toNodeView(
|
||||
node: SystemsMapNode,
|
||||
locale: AppLocale,
|
||||
relationshipLabel: string,
|
||||
): SystemsMapNodeView {
|
||||
const connected = new Set(connectedNodeIds(node.id));
|
||||
const connectedLabels = SYSTEMS_MAP_NODES.filter((entry) => connected.has(entry.id)).map(
|
||||
(entry) => entry.label[locale],
|
||||
private openDialog(id: SystemsMapNodeId, opener: HTMLElement): void {
|
||||
this.opener = opener;
|
||||
this.openNodeId.set(id);
|
||||
|
||||
afterNextRender(
|
||||
() => {
|
||||
const dialog = this.dialogRef()?.nativeElement;
|
||||
const target = this.focusableIn(dialog)[0] ?? dialog;
|
||||
target?.focus();
|
||||
},
|
||||
{ injector: this.injector },
|
||||
);
|
||||
const relationship = relationshipLabel.replace('{targets}', connectedLabels.join(', '));
|
||||
}
|
||||
|
||||
private focusableIn(root: HTMLElement | undefined): HTMLElement[] {
|
||||
if (!root) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
root.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
).filter((element) => !element.hasAttribute('hidden'));
|
||||
}
|
||||
|
||||
private toNodeView(node: SystemsMapNode, locale: AppLocale): SystemsMapNodeView {
|
||||
const copy = this.copy();
|
||||
const label = node.label[locale];
|
||||
const labelLines = node.labelLines[locale];
|
||||
const summary = node.summary[locale];
|
||||
const gist = node.gist[locale];
|
||||
const relationship = node.relationships[locale];
|
||||
const multiline = labelLines.length > 1;
|
||||
const longest = labelLines.reduce((max, line) => Math.max(max, line.length), 0);
|
||||
const linkPattern = node.kind === 'project' ? copy.caseLinkPattern : copy.sectionLinkPattern;
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
kind: node.kind,
|
||||
routeId: node.routeId,
|
||||
fragment: node.fragment,
|
||||
x: node.x,
|
||||
y: node.y,
|
||||
label,
|
||||
labelLines,
|
||||
summary,
|
||||
gist,
|
||||
shapeWidth: Math.max(140, longest * 8 + 24),
|
||||
shapeHeight: multiline ? 52 : 40,
|
||||
textStartDy: multiline ? -6 : 4,
|
||||
href: routePath(node.routeId, locale),
|
||||
href: nodeHref(node, locale),
|
||||
link: this.navigation.link(node.routeId),
|
||||
relationship,
|
||||
accessibleName: `${label}. ${summary} ${relationship}`,
|
||||
linkLabel: linkPattern.replace('{node}', label),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user