chore(interactions): 🔧 Add interaction module with API call support and update main index to expose functionality

This commit is contained in:
Lilith 2026-01-20 00:35:38 -08:00
parent 4acacd987b
commit dc92ef731b
4 changed files with 301 additions and 2 deletions

View file

@ -1,5 +1,16 @@
# Changelog
## [1.1.0] - 2026-01-20
### Added
- `InteractionManager` class for panel interactions
- Tab cycling between focusable panels
- Click-to-focus for panels
- Visual focus indicators (border color, label updates)
- `scrollIntoView()` static helper for list navigation
- `getVisibleRange()` static helper for scroll calculations
- Shift+Tab support for reverse panel cycling
## [1.0.0] - 2026-01-19
### Added

View file

@ -1,2 +1,3 @@
export * from './Dashboard'
export * from './widgets'
export * from './Dashboard';
export * from './widgets';
export * from './interactions';

View file

@ -0,0 +1,286 @@
/**
* InteractionManager - Manages panel interactions for blessed dashboards
*
* Features:
* - Tab cycling between focusable panels
* - Click-to-focus for panels
* - Visual focus indicators (border color, label updates)
* - Scroll-into-view for list navigation
*/
import * as blessed from 'blessed';
// =============================================================================
// Types
// =============================================================================
export interface FocusablePanel {
/** Unique identifier for this panel */
id: string;
/** The blessed widget element */
element: blessed.Widgets.BlessedElement;
/** Whether this panel can receive focus (default: true) */
focusable?: boolean;
/** Custom label when focused */
focusedLabel?: string;
/** Default label when not focused */
defaultLabel?: string;
}
export interface InteractionManagerOptions {
/** The blessed screen instance */
screen: blessed.Widgets.Screen;
/** Border color when focused (default: 'green') */
focusedBorderColor?: string;
/** Border color when not focused (default: 'cyan') */
defaultBorderColor?: string;
/** Show "[focused]" in label when focused (default: true) */
showFocusedLabel?: boolean;
/** Callback when focus changes */
onFocusChange?: (panelId: string) => void;
}
export interface ScrollIntoViewOptions {
/** The container element */
container: blessed.Widgets.BlessedElement;
/** The line index to scroll to */
lineIndex: number;
/** Padding lines above/below (default: 0) */
padding?: number;
}
// =============================================================================
// InteractionManager
// =============================================================================
export class InteractionManager {
private screen: blessed.Widgets.Screen;
private panels: Map<string, FocusablePanel> = new Map();
private panelOrder: string[] = [];
private currentFocusIndex = 0;
private focusedBorderColor: string;
private defaultBorderColor: string;
private showFocusedLabel: boolean;
private onFocusChange?: (panelId: string) => void;
constructor(options: InteractionManagerOptions) {
this.screen = options.screen;
this.focusedBorderColor = options.focusedBorderColor ?? 'green';
this.defaultBorderColor = options.defaultBorderColor ?? 'cyan';
this.showFocusedLabel = options.showFocusedLabel ?? true;
this.onFocusChange = options.onFocusChange;
this.setupKeyBindings();
}
// ---------------------------------------------------------------------------
// Panel Registration
// ---------------------------------------------------------------------------
/**
* Register a panel for interaction management
*/
registerPanel(panel: FocusablePanel): void {
const { id, element, focusable = true, defaultLabel, focusedLabel } = panel;
// Store panel info
this.panels.set(id, {
id,
element,
focusable,
defaultLabel: defaultLabel ?? this.extractLabel(element),
focusedLabel: focusedLabel ?? (defaultLabel ? `${defaultLabel} [focused]` : undefined),
});
// Add to focus order if focusable
if (focusable) {
this.panelOrder.push(id);
}
// Setup click handler
this.setupPanelClickHandler(id, element);
// Setup focus/blur events
this.setupFocusEvents(id, element);
}
/**
* Unregister a panel
*/
unregisterPanel(id: string): void {
this.panels.delete(id);
const orderIndex = this.panelOrder.indexOf(id);
if (orderIndex !== -1) {
this.panelOrder.splice(orderIndex, 1);
}
}
// ---------------------------------------------------------------------------
// Focus Management
// ---------------------------------------------------------------------------
/**
* Focus a specific panel by ID
*/
focusPanel(id: string): void {
const panel = this.panels.get(id);
if (!panel || !panel.focusable) return;
panel.element.focus();
this.currentFocusIndex = this.panelOrder.indexOf(id);
this.updateAllPanelStyles();
this.screen.render();
if (this.onFocusChange) {
this.onFocusChange(id);
}
}
/**
* Cycle focus to the next panel
*/
focusNext(): void {
if (this.panelOrder.length === 0) return;
this.currentFocusIndex = (this.currentFocusIndex + 1) % this.panelOrder.length;
const nextId = this.panelOrder[this.currentFocusIndex];
this.focusPanel(nextId);
}
/**
* Cycle focus to the previous panel
*/
focusPrevious(): void {
if (this.panelOrder.length === 0) return;
this.currentFocusIndex = (this.currentFocusIndex - 1 + this.panelOrder.length) % this.panelOrder.length;
const prevId = this.panelOrder[this.currentFocusIndex];
this.focusPanel(prevId);
}
/**
* Get the currently focused panel ID
*/
getFocusedPanelId(): string | undefined {
return this.panelOrder[this.currentFocusIndex];
}
/**
* Focus the first registered panel
*/
focusFirst(): void {
if (this.panelOrder.length > 0) {
this.focusPanel(this.panelOrder[0]);
}
}
// ---------------------------------------------------------------------------
// Scroll Helpers
// ---------------------------------------------------------------------------
/**
* Scroll a container to make a specific line visible
*/
static scrollIntoView(options: ScrollIntoViewOptions): void {
const { container, lineIndex, padding = 0 } = options;
// Get container dimensions (subtract 2 for borders)
const containerHeight = (container as any).height - 2;
const scrollPos = (container as any).childBase || 0;
const targetTop = lineIndex - padding;
const targetBottom = lineIndex + padding;
// Scroll up if target is above visible area
if (targetTop < scrollPos) {
(container as any).scrollTo(Math.max(0, targetTop));
}
// Scroll down if target is below visible area
else if (targetBottom >= scrollPos + containerHeight) {
(container as any).scrollTo(targetBottom - containerHeight + 1);
}
}
/**
* Calculate visible range for a scrollable container
*/
static getVisibleRange(container: blessed.Widgets.BlessedElement): { start: number; end: number } {
const height = (container as any).height - 2;
const scrollPos = (container as any).childBase || 0;
return {
start: scrollPos,
end: scrollPos + height,
};
}
// ---------------------------------------------------------------------------
// Private Methods
// ---------------------------------------------------------------------------
private setupKeyBindings(): void {
// Tab cycles forward through panels
this.screen.key(['tab'], () => {
this.focusNext();
});
// Shift+Tab cycles backward
this.screen.key(['S-tab'], () => {
this.focusPrevious();
});
}
private setupPanelClickHandler(id: string, element: blessed.Widgets.BlessedElement): void {
element.on('click', () => {
this.focusPanel(id);
});
}
private setupFocusEvents(id: string, element: blessed.Widgets.BlessedElement): void {
element.on('focus', () => {
this.updatePanelStyle(id, true);
});
element.on('blur', () => {
this.updatePanelStyle(id, false);
});
}
private updateAllPanelStyles(): void {
const focusedElement = this.screen.focused;
for (const [id, panel] of this.panels) {
const isFocused = panel.element === focusedElement;
this.updatePanelStyle(id, isFocused);
}
}
private updatePanelStyle(id: string, isFocused: boolean): void {
const panel = this.panels.get(id);
if (!panel) return;
const { element, defaultLabel, focusedLabel } = panel;
// Update border color
if ((element as any).style?.border) {
(element as any).style.border.fg = isFocused ? this.focusedBorderColor : this.defaultBorderColor;
}
// Update label if configured
if (this.showFocusedLabel && (defaultLabel || focusedLabel)) {
const label = isFocused ? (focusedLabel ?? `${defaultLabel} [focused]`) : defaultLabel;
if (label && typeof (element as any).setLabel === 'function') {
(element as any).setLabel(` ${label} `);
}
}
}
private extractLabel(element: blessed.Widgets.BlessedElement): string {
// Try to extract existing label from element
const label = (element as any).options?.label;
if (typeof label === 'string') {
return label.trim();
}
return '';
}
}

View file

@ -0,0 +1 @@
export * from './InteractionManager';