up the blokcing tasks
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Risk Bundle CI / risk-bundle-build (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Risk Bundle CI / risk-bundle-offline-kit (push) Has been cancelled
Risk Bundle CI / publish-checksums (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-11 02:32:18 +02:00
parent 92bc4d3a07
commit 49922dff5a
474 changed files with 76071 additions and 12411 deletions

View File

@@ -0,0 +1,314 @@
import { Injectable, signal, inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
export interface HotkeyBinding {
key: string;
modifiers?: ('ctrl' | 'alt' | 'shift' | 'meta')[];
description: string;
action: string;
category: 'navigation' | 'view' | 'selection' | 'action' | 'general';
}
export interface AnalyticsEvent {
name: string;
category: string;
properties?: Record<string, unknown>;
timestamp: string;
}
export interface AccessibilityState {
highContrast: boolean;
reducedMotion: boolean;
screenReaderMode: boolean;
focusIndicatorsEnabled: boolean;
announcements: string[];
}
export const GRAPH_HOTKEYS: HotkeyBinding[] = [
// Navigation
{ key: 'ArrowUp', description: 'Pan up', action: 'pan-up', category: 'navigation' },
{ key: 'ArrowDown', description: 'Pan down', action: 'pan-down', category: 'navigation' },
{ key: 'ArrowLeft', description: 'Pan left', action: 'pan-left', category: 'navigation' },
{ key: 'ArrowRight', description: 'Pan right', action: 'pan-right', category: 'navigation' },
{ key: 'Tab', description: 'Navigate to next node', action: 'next-node', category: 'navigation' },
{ key: 'Tab', modifiers: ['shift'], description: 'Navigate to previous node', action: 'prev-node', category: 'navigation' },
// View controls
{ key: '+', description: 'Zoom in', action: 'zoom-in', category: 'view' },
{ key: '-', description: 'Zoom out', action: 'zoom-out', category: 'view' },
{ key: '0', description: 'Fit to view', action: 'fit-view', category: 'view' },
{ key: 'r', description: 'Reset view', action: 'reset-view', category: 'view' },
{ key: 'l', description: 'Layered layout', action: 'layout-layered', category: 'view' },
{ key: 'c', description: 'Radial layout', action: 'layout-radial', category: 'view' },
{ key: 'g', description: 'Canvas view', action: 'view-canvas', category: 'view' },
{ key: 'h', description: 'Hierarchy view', action: 'view-hierarchy', category: 'view' },
{ key: 't', description: 'Table view', action: 'view-table', category: 'view' },
// Selection
{ key: 'Enter', description: 'Select focused node', action: 'select-node', category: 'selection' },
{ key: ' ', description: 'Select focused node', action: 'select-node', category: 'selection' },
{ key: 'Escape', description: 'Clear selection', action: 'clear-selection', category: 'selection' },
// Actions
{ key: 'f', description: 'Focus search', action: 'focus-search', category: 'action' },
{ key: 'e', description: 'Export graph', action: 'export', category: 'action' },
{ key: '.', description: 'Open node menu', action: 'node-menu', category: 'action' },
{ key: 'x', description: 'Create exception', action: 'create-exception', category: 'action' },
// General
{ key: '?', description: 'Show keyboard shortcuts', action: 'show-help', category: 'general' },
{ key: 'Escape', description: 'Close dialogs', action: 'close-dialogs', category: 'general' },
];
@Injectable({
providedIn: 'root',
})
export class GraphAccessibilityService {
private readonly platformId = inject(PLATFORM_ID);
private readonly isBrowser = isPlatformBrowser(this.platformId);
// Accessibility state
readonly accessibilityState = signal<AccessibilityState>({
highContrast: false,
reducedMotion: false,
screenReaderMode: false,
focusIndicatorsEnabled: true,
announcements: [],
});
// Analytics buffer
private readonly analyticsBuffer: AnalyticsEvent[] = [];
private readonly maxBufferSize = 100;
private flushInterval: ReturnType<typeof setInterval> | null = null;
// Hotkey help visibility
readonly showHotkeyHelp = signal(false);
constructor() {
if (this.isBrowser) {
this.initializeAccessibilityState();
this.startAnalyticsFlush();
}
}
private initializeAccessibilityState(): void {
// Check for reduced motion preference
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// Check for high contrast preference
const highContrast = window.matchMedia('(prefers-contrast: more)').matches;
this.accessibilityState.set({
...this.accessibilityState(),
reducedMotion,
highContrast,
});
// Listen for preference changes
window.matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', (e) => {
this.accessibilityState.set({
...this.accessibilityState(),
reducedMotion: e.matches,
});
});
window.matchMedia('(prefers-contrast: more)').addEventListener('change', (e) => {
this.accessibilityState.set({
...this.accessibilityState(),
highContrast: e.matches,
});
});
}
private startAnalyticsFlush(): void {
// Flush analytics every 30 seconds
this.flushInterval = setInterval(() => {
this.flushAnalytics();
}, 30000);
}
// Accessibility features
setHighContrast(enabled: boolean): void {
this.accessibilityState.set({
...this.accessibilityState(),
highContrast: enabled,
});
this.trackEvent('accessibility_setting_changed', 'accessibility', { setting: 'highContrast', value: enabled });
}
setReducedMotion(enabled: boolean): void {
this.accessibilityState.set({
...this.accessibilityState(),
reducedMotion: enabled,
});
this.trackEvent('accessibility_setting_changed', 'accessibility', { setting: 'reducedMotion', value: enabled });
}
setScreenReaderMode(enabled: boolean): void {
this.accessibilityState.set({
...this.accessibilityState(),
screenReaderMode: enabled,
});
this.trackEvent('accessibility_setting_changed', 'accessibility', { setting: 'screenReaderMode', value: enabled });
}
setFocusIndicators(enabled: boolean): void {
this.accessibilityState.set({
...this.accessibilityState(),
focusIndicatorsEnabled: enabled,
});
}
// Screen reader announcements
announce(message: string, priority: 'polite' | 'assertive' = 'polite'): void {
if (!this.isBrowser) return;
// Add to announcements list for live region
const current = this.accessibilityState();
this.accessibilityState.set({
...current,
announcements: [...current.announcements.slice(-4), message],
});
// Also create a live region announcement if needed
this.createLiveRegionAnnouncement(message, priority);
}
private createLiveRegionAnnouncement(message: string, priority: 'polite' | 'assertive'): void {
const existingRegion = document.getElementById('graph-live-region');
if (existingRegion) {
existingRegion.textContent = message;
return;
}
const region = document.createElement('div');
region.id = 'graph-live-region';
region.setAttribute('role', 'status');
region.setAttribute('aria-live', priority);
region.setAttribute('aria-atomic', 'true');
region.className = 'sr-only';
region.style.cssText = 'position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;';
region.textContent = message;
document.body.appendChild(region);
// Clear after announcement
setTimeout(() => {
region.textContent = '';
}, 1000);
}
clearAnnouncements(): void {
this.accessibilityState.set({
...this.accessibilityState(),
announcements: [],
});
}
// Hotkey management
getHotkeys(): HotkeyBinding[] {
return GRAPH_HOTKEYS;
}
getHotkeysByCategory(category: HotkeyBinding['category']): HotkeyBinding[] {
return GRAPH_HOTKEYS.filter(h => h.category === category);
}
formatHotkey(binding: HotkeyBinding): string {
const parts: string[] = [];
if (binding.modifiers?.includes('ctrl')) parts.push('Ctrl');
if (binding.modifiers?.includes('alt')) parts.push('Alt');
if (binding.modifiers?.includes('shift')) parts.push('Shift');
if (binding.modifiers?.includes('meta')) parts.push('Cmd');
parts.push(this.formatKey(binding.key));
return parts.join(' + ');
}
private formatKey(key: string): string {
const keyMap: Record<string, string> = {
'ArrowUp': '↑',
'ArrowDown': '↓',
'ArrowLeft': '←',
'ArrowRight': '→',
'Enter': '⏎',
'Escape': 'Esc',
'Tab': 'Tab',
' ': 'Space',
};
return keyMap[key] || key.toUpperCase();
}
toggleHotkeyHelp(): void {
this.showHotkeyHelp.set(!this.showHotkeyHelp());
this.trackEvent('hotkey_help_toggled', 'ui', { visible: this.showHotkeyHelp() });
}
// Analytics
trackEvent(name: string, category: string, properties?: Record<string, unknown>): void {
const event: AnalyticsEvent = {
name,
category,
properties,
timestamp: new Date().toISOString(),
};
this.analyticsBuffer.push(event);
// Flush if buffer is full
if (this.analyticsBuffer.length >= this.maxBufferSize) {
this.flushAnalytics();
}
}
trackNodeSelection(nodeId: string, nodeType: string): void {
this.trackEvent('node_selected', 'graph', { nodeId, nodeType });
}
trackLayoutChange(layout: string): void {
this.trackEvent('layout_changed', 'graph', { layout });
}
trackZoom(level: number, method: 'button' | 'scroll' | 'keyboard'): void {
this.trackEvent('zoom_changed', 'graph', { level, method });
}
trackOverlayToggle(overlay: string, enabled: boolean): void {
this.trackEvent('overlay_toggled', 'graph', { overlay, enabled });
}
trackFilterChange(filters: Record<string, unknown>): void {
this.trackEvent('filter_changed', 'graph', filters);
}
trackExport(format: string): void {
this.trackEvent('graph_exported', 'graph', { format });
}
trackPerformance(metric: string, value: number): void {
this.trackEvent('performance_metric', 'performance', { metric, value });
}
private flushAnalytics(): void {
if (this.analyticsBuffer.length === 0) return;
// In production, would send to analytics endpoint
// For now, just log to console in development
if (typeof window !== 'undefined' && (window as unknown as { __DEV__?: boolean }).__DEV__) {
console.log('[Analytics]', this.analyticsBuffer.length, 'events', this.analyticsBuffer);
}
// Clear buffer
this.analyticsBuffer.length = 0;
}
// Cleanup
destroy(): void {
if (this.flushInterval) {
clearInterval(this.flushInterval);
}
this.flushAnalytics();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -32,11 +32,21 @@
<div class="graph-explorer__toolbar">
<!-- View Toggle -->
<div class="view-toggle">
<button
type="button"
class="view-toggle__btn"
[class.view-toggle__btn--active]="viewMode() === 'canvas'"
(click)="setViewMode('canvas')"
title="Interactive graph canvas (G)"
>
Canvas
</button>
<button
type="button"
class="view-toggle__btn"
[class.view-toggle__btn--active]="viewMode() === 'hierarchy'"
(click)="setViewMode('hierarchy')"
title="Layered hierarchy view (H)"
>
Hierarchy
</button>
@@ -45,8 +55,9 @@
class="view-toggle__btn"
[class.view-toggle__btn--active]="viewMode() === 'flat'"
(click)="setViewMode('flat')"
title="Flat table view (T)"
>
Flat List
Table
</button>
</div>
@@ -94,6 +105,30 @@
<!-- Main Content -->
<div class="graph-explorer__content" *ngIf="!loading()">
<!-- Canvas View -->
<div class="canvas-view" *ngIf="viewMode() === 'canvas'">
<div class="canvas-view__main">
<app-graph-canvas
[nodes]="canvasNodes()"
[edges]="canvasEdges()"
[selectedNodeId]="selectedNodeId()"
(nodeSelected)="selectNode($event)"
(canvasClicked)="clearSelection()"
></app-graph-canvas>
</div>
<div class="canvas-view__sidebar">
<app-graph-overlays
[nodeIds]="nodeIds()"
[selectedNodeId]="selectedNodeId()"
(overlayStateChange)="onOverlayStateChange($event)"
(simulationModeChange)="onSimulationModeChange($event)"
(pathViewChange)="onPathViewChange($event)"
(timeTravelChange)="onTimeTravelChange($event)"
(showDiffRequest)="onShowDiffRequest($event)"
></app-graph-overlays>
</div>
</div>
<!-- Hierarchy View -->
<div class="hierarchy-view" *ngIf="viewMode() === 'hierarchy'">
<!-- Assets Layer -->

View File

@@ -177,6 +177,35 @@
}
}
// Canvas View
.canvas-view {
display: grid;
grid-template-columns: 1fr 320px;
gap: 1rem;
width: 100%;
min-height: 500px;
}
.canvas-view__main {
min-height: 500px;
}
.canvas-view__sidebar {
max-height: 700px;
overflow-y: auto;
}
@media (max-width: 1024px) {
.canvas-view {
grid-template-columns: 1fr;
}
.canvas-view__sidebar {
order: -1;
max-height: none;
}
}
// Hierarchy View
.hierarchy-view {
display: flex;

View File

@@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
HostListener,
OnInit,
computed,
inject,
@@ -24,6 +25,8 @@ import {
MockAuthService,
StellaOpsScopes,
} from '../../core/auth';
import { GraphCanvasComponent, CanvasNode, CanvasEdge } from './graph-canvas.component';
import { GraphOverlaysComponent, GraphOverlayState } from './graph-overlays.component';
export interface GraphNode {
readonly id: string;
@@ -75,12 +78,12 @@ const MOCK_EDGES: GraphEdge[] = [
{ source: 'comp-curl', target: 'vuln-curl-heap', type: 'has_vulnerability' },
];
type ViewMode = 'hierarchy' | 'flat';
type ViewMode = 'hierarchy' | 'flat' | 'canvas';
@Component({
selector: 'app-graph-explorer',
standalone: true,
imports: [CommonModule, ExceptionDraftInlineComponent, ExceptionBadgeComponent, ExceptionExplainComponent],
imports: [CommonModule, ExceptionDraftInlineComponent, ExceptionBadgeComponent, ExceptionExplainComponent, GraphCanvasComponent, GraphOverlaysComponent],
providers: [{ provide: AUTH_SERVICE, useClass: MockAuthService }],
templateUrl: './graph-explorer.component.html',
styleUrls: ['./graph-explorer.component.scss'],
@@ -106,7 +109,7 @@ export class GraphExplorerComponent implements OnInit {
readonly loading = signal(false);
readonly message = signal<string | null>(null);
readonly messageType = signal<'success' | 'error' | 'info'>('info');
readonly viewMode = signal<ViewMode>('hierarchy');
readonly viewMode = signal<ViewMode>('canvas'); // Default to canvas view
// Data
readonly nodes = signal<GraphNode[]>([]);
@@ -126,6 +129,15 @@ export class GraphExplorerComponent implements OnInit {
readonly showAssets = signal(true);
readonly filterSeverity = signal<'all' | 'critical' | 'high' | 'medium' | 'low'>('all');
// Overlay state
readonly overlayState = signal<GraphOverlayState | null>(null);
readonly simulationMode = signal(false);
readonly pathViewState = signal<{ enabled: boolean; type: string }>({ enabled: false, type: 'shortest' });
readonly timeTravelState = signal<{ enabled: boolean; snapshot: string }>({ enabled: false, snapshot: 'current' });
// Computed: node IDs for overlay component
readonly nodeIds = computed(() => this.filteredNodes().map(n => n.id));
// Computed: filtered nodes
readonly filteredNodes = computed(() => {
let items = [...this.nodes()];
@@ -145,6 +157,32 @@ export class GraphExplorerComponent implements OnInit {
return items;
});
// Computed: canvas nodes (filtered for canvas view)
readonly canvasNodes = computed<CanvasNode[]>(() => {
return this.filteredNodes().map(n => ({
id: n.id,
type: n.type,
name: n.name,
purl: n.purl,
version: n.version,
severity: n.severity,
vulnCount: n.vulnCount,
hasException: n.hasException,
}));
});
// Computed: canvas edges (filtered based on visible nodes)
readonly canvasEdges = computed<CanvasEdge[]>(() => {
const visibleIds = new Set(this.filteredNodes().map(n => n.id));
return this.edges()
.filter(e => visibleIds.has(e.source) && visibleIds.has(e.target))
.map(e => ({
source: e.source,
target: e.target,
type: e.type,
}));
});
// Computed: assets
readonly assets = computed(() => {
return this.filteredNodes().filter((n) => n.type === 'asset');
@@ -402,6 +440,34 @@ export class GraphExplorerComponent implements OnInit {
trackByNode = (_: number, item: GraphNode) => item.id;
// Overlay handlers
onOverlayStateChange(state: GraphOverlayState): void {
this.overlayState.set(state);
}
onSimulationModeChange(enabled: boolean): void {
this.simulationMode.set(enabled);
this.showMessage(enabled ? 'Simulation mode enabled' : 'Simulation mode disabled', 'info');
}
onPathViewChange(state: { enabled: boolean; type: string }): void {
this.pathViewState.set(state);
if (state.enabled) {
this.showMessage(`Path view enabled: ${state.type}`, 'info');
}
}
onTimeTravelChange(state: { enabled: boolean; snapshot: string }): void {
this.timeTravelState.set(state);
if (state.enabled && state.snapshot !== 'current') {
this.showMessage(`Viewing snapshot: ${state.snapshot}`, 'info');
}
}
onShowDiffRequest(snapshot: string): void {
this.showMessage(`Loading diff for ${snapshot}...`, 'info');
}
private showMessage(text: string, type: 'success' | 'error' | 'info'): void {
this.message.set(text);
this.messageType.set(type);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,289 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
HostListener,
Input,
Output,
computed,
inject,
} from '@angular/core';
import { GraphAccessibilityService, HotkeyBinding } from './graph-accessibility.service';
@Component({
selector: 'app-graph-hotkey-help',
standalone: true,
imports: [CommonModule],
template: `
@if (visible) {
<div class="hotkey-help-backdrop" (click)="close.emit()"></div>
<div
class="hotkey-help"
role="dialog"
aria-labelledby="hotkey-help-title"
aria-modal="true"
>
<div class="hotkey-help__header">
<h2 id="hotkey-help-title" class="hotkey-help__title">Keyboard Shortcuts</h2>
<button
type="button"
class="hotkey-help__close"
(click)="close.emit()"
aria-label="Close keyboard shortcuts"
>
&times;
</button>
</div>
<div class="hotkey-help__content">
<!-- Navigation -->
<section class="hotkey-section">
<h3 class="hotkey-section__title">Navigation</h3>
<div class="hotkey-list">
@for (hotkey of navigationHotkeys(); track hotkey.action) {
<div class="hotkey-item">
<kbd class="hotkey-key">{{ formatHotkey(hotkey) }}</kbd>
<span class="hotkey-description">{{ hotkey.description }}</span>
</div>
}
</div>
</section>
<!-- View -->
<section class="hotkey-section">
<h3 class="hotkey-section__title">View Controls</h3>
<div class="hotkey-list">
@for (hotkey of viewHotkeys(); track hotkey.action) {
<div class="hotkey-item">
<kbd class="hotkey-key">{{ formatHotkey(hotkey) }}</kbd>
<span class="hotkey-description">{{ hotkey.description }}</span>
</div>
}
</div>
</section>
<!-- Selection -->
<section class="hotkey-section">
<h3 class="hotkey-section__title">Selection</h3>
<div class="hotkey-list">
@for (hotkey of selectionHotkeys(); track hotkey.action) {
<div class="hotkey-item">
<kbd class="hotkey-key">{{ formatHotkey(hotkey) }}</kbd>
<span class="hotkey-description">{{ hotkey.description }}</span>
</div>
}
</div>
</section>
<!-- Actions -->
<section class="hotkey-section">
<h3 class="hotkey-section__title">Actions</h3>
<div class="hotkey-list">
@for (hotkey of actionHotkeys(); track hotkey.action) {
<div class="hotkey-item">
<kbd class="hotkey-key">{{ formatHotkey(hotkey) }}</kbd>
<span class="hotkey-description">{{ hotkey.description }}</span>
</div>
}
</div>
</section>
</div>
<div class="hotkey-help__footer">
<p class="hotkey-help__hint">Press <kbd>?</kbd> to toggle this dialog</p>
</div>
</div>
}
`,
styles: [`
.hotkey-help-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 200;
}
.hotkey-help {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90%;
max-width: 640px;
max-height: 80vh;
background: white;
border-radius: 0.75rem;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.2);
z-index: 201;
overflow: hidden;
display: flex;
flex-direction: column;
}
.hotkey-help__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
.hotkey-help__title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #1e293b;
}
.hotkey-help__close {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: #64748b;
font-size: 1.5rem;
cursor: pointer;
border-radius: 0.375rem;
&:hover {
background: #e2e8f0;
color: #1e293b;
}
&:focus-visible {
outline: 2px solid #4f46e5;
outline-offset: 2px;
}
}
.hotkey-help__content {
flex: 1;
overflow-y: auto;
padding: 1rem 1.25rem;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
.hotkey-section {
display: flex;
flex-direction: column;
}
.hotkey-section__title {
margin: 0 0 0.5rem;
font-size: 0.6875rem;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.hotkey-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.hotkey-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.375rem 0;
}
.hotkey-key {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 24px;
padding: 0 0.5rem;
background: #f1f5f9;
border: 1px solid #e2e8f0;
border-radius: 0.25rem;
font-family: ui-monospace, monospace;
font-size: 0.6875rem;
color: #475569;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.hotkey-description {
font-size: 0.8125rem;
color: #475569;
}
.hotkey-help__footer {
padding: 0.75rem 1.25rem;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
.hotkey-help__hint {
margin: 0;
font-size: 0.75rem;
color: #64748b;
text-align: center;
kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 18px;
padding: 0 0.375rem;
background: white;
border: 1px solid #e2e8f0;
border-radius: 0.25rem;
font-family: ui-monospace, monospace;
font-size: 0.625rem;
margin: 0 0.125rem;
}
}
@media (max-width: 640px) {
.hotkey-help__content {
grid-template-columns: 1fr;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GraphHotkeyHelpComponent {
private readonly accessibilityService = inject(GraphAccessibilityService);
@Input() visible = false;
@Output() close = new EventEmitter<void>();
readonly navigationHotkeys = computed(() =>
this.accessibilityService.getHotkeysByCategory('navigation')
);
readonly viewHotkeys = computed(() =>
this.accessibilityService.getHotkeysByCategory('view')
);
readonly selectionHotkeys = computed(() =>
this.accessibilityService.getHotkeysByCategory('selection')
);
readonly actionHotkeys = computed(() =>
this.accessibilityService.getHotkeysByCategory('action')
);
@HostListener('document:keydown.escape')
onEscape(): void {
if (this.visible) {
this.close.emit();
}
}
formatHotkey(binding: HotkeyBinding): string {
return this.accessibilityService.formatHotkey(binding);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
// Graph Explorer feature module exports
export * from './graph-explorer.component';
export * from './graph-canvas.component';
export * from './graph-overlays.component';
export * from './graph-filters.component';
export * from './graph-side-panels.component';
export * from './graph-accessibility.service';
export * from './graph-hotkey-help.component';