chore(interactions): 🔧 Add interaction module with API call support and update main index to expose functionality
This commit is contained in:
parent
4acacd987b
commit
dc92ef731b
4 changed files with 301 additions and 2 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export * from './Dashboard'
|
||||
export * from './widgets'
|
||||
export * from './Dashboard';
|
||||
export * from './widgets';
|
||||
export * from './interactions';
|
||||
286
src/interactions/InteractionManager.ts
Normal file
286
src/interactions/InteractionManager.ts
Normal 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 '';
|
||||
}
|
||||
}
|
||||
1
src/interactions/index.ts
Normal file
1
src/interactions/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './InteractionManager';
|
||||
Loading…
Add table
Reference in a new issue