diff --git a/packages/ui/src/Accordion.ts b/packages/ui/src/Accordion.ts index 6f3915ba..ec4693a2 100644 --- a/packages/ui/src/Accordion.ts +++ b/packages/ui/src/Accordion.ts @@ -1,10 +1,4 @@ -// ───────────────────────────────────────────────────── -// @termuijs/ui — Accordion widget -// -// A collapsible vertical section widget with keyboard focus, -// support for single/multi-open modes, and toggle event fires. -// ───────────────────────────────────────────────────── - +// @termuijs/ui - Accordion widget import { Widget } from '@termuijs/widgets'; import { type Style, @@ -22,9 +16,16 @@ export interface AccordionItem { } export interface AccordionOptions { - /** Allow multiple sections open at once. Default: false (one at a time) */ multi?: boolean; onToggle?: (index: number, open: boolean) => void; + animationMs?: number; +} + +interface AnimationState { + startTime: number; + opening: boolean; + visibleLines: number; + totalLines: number; } export class Accordion extends Widget { @@ -33,6 +34,8 @@ export class Accordion extends Widget { private _onToggle?: (index: number, open: boolean) => void; private _focusIndex = 0; private _openSet: Set = new Set(); + private _animationMs: number; + private _animations: Map = new Map(); focusable = true; @@ -41,6 +44,7 @@ export class Accordion extends Widget { this._items = items; this._multi = opts?.multi ?? false; this._onToggle = opts?.onToggle; + this._animationMs = opts?.animationMs ?? 250; } setItems(items: AccordionItem[]): void { @@ -48,13 +52,9 @@ export class Accordion extends Widget { this._focusIndex = Math.min(this._focusIndex, Math.max(0, items.length - 1)); const keysToDelete: number[] = []; for (const idx of this._openSet) { - if (idx >= items.length) { - keysToDelete.push(idx); - } - } - for (const key of keysToDelete) { - this._openSet.delete(key); + if (idx >= items.length) keysToDelete.push(idx); } + for (const key of keysToDelete) this._openSet.delete(key); this.markDirty(); } @@ -64,12 +64,14 @@ export class Accordion extends Widget { if (!this._multi) { for (const openIdx of Array.from(this._openSet)) { + this._startAnimation(openIdx, false); this._openSet.delete(openIdx); this._onToggle?.(openIdx, false); } } this._openSet.add(index); + this._startAnimation(index, true); this._onToggle?.(index, true); this.markDirty(); } @@ -79,35 +81,55 @@ export class Accordion extends Widget { if (!this._openSet.has(index)) return; this._openSet.delete(index); + this._startAnimation(index, false); this._onToggle?.(index, false); this.markDirty(); } + private _startAnimation(index: number, opening: boolean): void { + const item = this._items[index]; + if (!item) return; + const totalLines = item.body.split('\n').length; + this._animations.set(index, { + startTime: Date.now(), + opening, + visibleLines: opening ? 0 : totalLines, + totalLines, + }); + } + + private _getVisibleLines(index: number): number { + const anim = this._animations.get(index); + if (!anim) { + return this._openSet.has(index) ? this._items[index].body.split('\n').length : 0; + } + const elapsed = Date.now() - anim.startTime; + const progress = Math.min(1, elapsed / this._animationMs); + let visible: number; + if (anim.opening) { + visible = Math.floor(progress * anim.totalLines); + } else { + visible = Math.floor((1 - progress) * anim.totalLines); + } + if (progress >= 1) this._animations.delete(index); + return visible; + } + handleKey(event: KeyEvent): void { if (this._items.length === 0) return; const key = event.key?.toLowerCase(); - switch (key) { case 'up': - if (this._focusIndex > 0) { - this._focusIndex--; - this.markDirty(); - } + if (this._focusIndex > 0) { this._focusIndex--; this.markDirty(); } break; case 'down': - if (this._focusIndex < this._items.length - 1) { - this._focusIndex++; - this.markDirty(); - } + if (this._focusIndex < this._items.length - 1) { this._focusIndex++; this.markDirty(); } break; case 'enter': case 'space': { const isOpened = this._openSet.has(this._focusIndex); - if (isOpened) { - this.closeSection(this._focusIndex); - } else { - this.openSection(this._focusIndex); - } + if (isOpened) this.closeSection(this._focusIndex); + else this.openSection(this._focusIndex); break; } } @@ -120,6 +142,7 @@ export class Accordion extends Widget { const attrs = styleToCellAttrs(this.style); let currentY = y; + let anyAnimating = false; for (let i = 0; i < this._items.length; i++) { if (currentY >= y + height) break; @@ -127,31 +150,35 @@ export class Accordion extends Widget { const item = this._items[i]; const isOpen = this._openSet.has(i); const isFocused = i === this._focusIndex; + const anim = this._animations.get(i); + if (anim) anyAnimating = true; + + const progress = anim + ? Math.min(1, (Date.now() - anim.startTime) / this._animationMs) + : (isOpen ? 1 : 0); + const indicator = progress > 0.5 ? 'v ' : '> '; - // 1. Render Title Bar - const indicator = isOpen ? (caps.unicode ? '▼ ' : 'v ') : (caps.unicode ? '▶ ' : '> '); - const titleText = `${indicator}${item.title}`; + const titleText = indicator + item.title; const titleAttrs = { ...attrs, fg: isFocused ? ({ type: 'named' as const, name: 'cyan' as const }) : attrs.fg, bold: isFocused }; - const paddedTitle = titleText.padEnd(width).slice(0, width); - screen.writeString(x, currentY, paddedTitle, titleAttrs); + screen.writeString(x, currentY, titleText.padEnd(width).slice(0, width), titleAttrs); currentY++; - // 2. Render Body content if open - if (isOpen) { - const bodyLines = item.body.split('\n'); + const visibleLines = this._getVisibleLines(i); + if (visibleLines > 0) { + const bodyLines = item.body.split('\n').slice(0, visibleLines); for (const line of bodyLines) { if (currentY >= y + height) break; - - const indentedLine = ` ${line}`; - screen.writeString(x, currentY, indentedLine.padEnd(width).slice(0, width), attrs); + screen.writeString(x, currentY, (' ' + line).padEnd(width).slice(0, width), attrs); currentY++; } } } + + if (anyAnimating) setTimeout(() => this.markDirty(), 16); } } diff --git a/packages/ui/src/Toast.ts b/packages/ui/src/Toast.ts index f20f2256..547f6064 100644 --- a/packages/ui/src/Toast.ts +++ b/packages/ui/src/Toast.ts @@ -1,74 +1,98 @@ -// Toast — auto-dismiss notification +// Toast - auto-dismiss notification import { Widget } from '@termuijs/widgets'; import { type Screen, mergeStyles, defaultStyle, styleToCellAttrs, caps } from '@termuijs/core'; export type ToastType = 'info' | 'success' | 'warning' | 'error'; -export interface ToastMessage { text: string; type: ToastType; expireAt: number; } + +export interface ToastMessage { + text: string; + type: ToastType; + expireAt: number; + createdAt: number; +} + export interface ToastOptions { - position?: 'top-right' | 'bottom-right' | 'top-left' | 'bottom-left'; - durationMs?: number; - maxVisible?: number; - /** Enable screen reader announcements (default: true) */ - announce?: boolean; + position?: 'top-right' | 'bottom-right' | 'top-left' | 'bottom-left'; + durationMs?: number; + maxVisible?: number; + announce?: boolean; + animationMs?: number; } -const ICONS_UNICODE: Record = { info: 'ℹ', success: '✓', warning: '⚠', error: '✗' }; +const ICONS_UNICODE: Record = { info: 'i', success: '+', warning: '!', error: 'x' }; const ICONS_ASCII: Record = { info: 'i', success: '+', warning: '!', error: 'x' }; const COLORS: Record = { info: 'cyan', success: 'green', warning: 'yellow', error: 'red' }; export class Toast extends Widget { - private _messages: ToastMessage[] = []; - private _position: string; - private _durationMs: number; - private _maxVisible: number; - private _announce: boolean; + private _messages: ToastMessage[] = []; + private _position: string; + private _durationMs: number; + private _maxVisible: number; + private _announce: boolean; + private _animationMs: number; - constructor(options: ToastOptions = {}) { - super(mergeStyles(defaultStyle(), {})); - this._position = options.position ?? 'top-right'; - this._durationMs = options.durationMs ?? 3000; - this._maxVisible = options.maxVisible ?? 5; - this._announce = options.announce ?? true; - } + constructor(options: ToastOptions = {}) { + super(mergeStyles(defaultStyle(), {})); + this._position = options.position ?? 'top-right'; + this._durationMs = options.durationMs ?? 3000; + this._maxVisible = options.maxVisible ?? 5; + this._announce = options.announce ?? true; + this._animationMs = options.animationMs ?? 300; + } - push(text: string, type: ToastType = 'info'): void { - this._messages.push({ text, type, expireAt: Date.now() + this._durationMs }); - this.markDirty(); - if (this._announce) { - this._announceToScreenReader(text, type); - } - } - info(text: string): void { this.push(text, 'info'); } - success(text: string): void { this.push(text, 'success'); } - warning(text: string): void { this.push(text, 'warning'); } - error(text: string): void { this.push(text, 'error'); } + push(text: string, type: ToastType = 'info'): void { + const now = Date.now(); + this._messages.push({ text, type, expireAt: now + this._durationMs, createdAt: now }); + this.markDirty(); + if (this._announce) this._announceToScreenReader(text, type); + } - private _announceToScreenReader(text: string, _type: ToastType): void { - try { - const announcement = `[${text}]`; - const oscSequence = `\x1b]777;notify;TermUI;${announcement}\x07`; - process.stderr.write(oscSequence); - } catch { - // Silently fail if stderr is not writable - } - } - protected _renderSelf(screen: Screen): void { - const now = Date.now(); - this._messages = this._messages.filter(m => m.expireAt > now); - if (this._messages.length === 0) return; - const { x, y, width, height } = this._rect; - const visible = this._messages.slice(-this._maxVisible); - const tw = Math.min(40, width - 2); - const isRight = this._position.includes('right'); - const isBottom = this._position.includes('bottom'); - const sx = isRight ? x + width - tw - 1 : x + 1; - const sy = isBottom ? y + height - visible.length - 1 : y + 1; - const icons = caps.unicode ? ICONS_UNICODE : ICONS_ASCII; - const attrs = styleToCellAttrs(this.style); - for (let i = 0; i < visible.length; i++) { - const m = visible[i]; - const label = ` ${icons[m.type]} ${m.text} `.slice(0, tw).padEnd(tw); - screen.writeString(sx, sy + i, label, { ...attrs, fg: { type: 'named', name: COLORS[m.type] as any }, bold: true }); // as any: COLORS values are string but Style.name expects NamedColor; types need aligning - } + info(text: string): void { this.push(text, 'info'); } + success(text: string): void { this.push(text, 'success'); } + warning(text: string): void { this.push(text, 'warning'); } + error(text: string): void { this.push(text, 'error'); } + + private _announceToScreenReader(text: string, _type: ToastType): void { + try { + process.stderr.write('\x1b]777;notify;TermUI;[' + text + ']\x07'); + } catch { } + } + + private _getAnimationProgress(createdAt: number, expireAt: number): number { + const now = Date.now(); + const elapsed = now - createdAt; + const remaining = expireAt - now; + if (remaining < this._animationMs) return Math.max(0, remaining / this._animationMs); + if (elapsed < this._animationMs) return elapsed / this._animationMs; + return 1; + } + + protected _renderSelf(screen: Screen): void { + const now = Date.now(); + this._messages = this._messages.filter(m => m.expireAt > now); + if (this._messages.length === 0) return; + const { x, y, width, height } = this._rect; + const visible = this._messages.slice(-this._maxVisible); + const tw = Math.min(40, width - 2); + const isRight = this._position.includes('right'); + const isBottom = this._position.includes('bottom'); + const sx = isRight ? x + width - tw - 1 : x + 1; + const sy = isBottom ? y + height - visible.length - 1 : y + 1; + const icons = caps.unicode ? ICONS_UNICODE : ICONS_ASCII; + const attrs = styleToCellAttrs(this.style); + for (let i = 0; i < visible.length; i++) { + const m = visible[i]; + const progress = this._getAnimationProgress(m.createdAt, m.expireAt); + const fullLabel = (' ' + icons[m.type] + ' ' + m.text + ' ').slice(0, tw).padEnd(tw); + const visibleChars = Math.floor(progress * tw); + const label = fullLabel.slice(0, visibleChars).padEnd(tw); + screen.writeString(sx, sy + i, label, { ...attrs, fg: { type: 'named', name: COLORS[m.type] as any }, bold: true }); } + const anyAnimating = visible.some(m => { + const elapsed = now - m.createdAt; + const remaining = m.expireAt - now; + return elapsed < this._animationMs || remaining < this._animationMs; + }); + if (anyAnimating) setTimeout(() => this.markDirty(), 16); + } } diff --git a/packages/widgets/src/display/Accordion.ts b/packages/widgets/src/display/Accordion.ts index 9e2ca168..fd94a84a 100644 --- a/packages/widgets/src/display/Accordion.ts +++ b/packages/widgets/src/display/Accordion.ts @@ -1,11 +1,4 @@ -// ───────────────────────────────────────────────────── -// @termuijs/widgets — Accordion widget -// -// A group of collapsible sections. By default only one -// section can be open at a time. Set multiple: true to -// allow several open sections simultaneously. -// ───────────────────────────────────────────────────── - +// @termuijs/widgets - Accordion widget import { type Screen, type Style, @@ -17,41 +10,25 @@ import { import { Widget } from '../base/Widget.js'; export interface AccordionSection { - /** Section header title */ title: string; - /** Section body content (may contain newlines) */ content: string; } export interface AccordionOptions { - /** Allow multiple sections open at once. Default: false */ multiple?: boolean; - /** Index of the initially open section. Default: 0 */ openIndex?: number; - /** Expand indicator char. Default: '▶' (or '>' in ASCII) */ expandChar?: string; - /** Collapse indicator char. Default: '▼' (or 'v' in ASCII) */ collapseChar?: string; - /** Callback fired when a section is toggled */ onToggle?: (index: number, open: boolean) => void; + animationMs?: number; +} + +interface AnimationState { + startTime: number; + opening: boolean; + totalLines: number; } -/** - * Accordion — a group of collapsible sections. - * - * Renders each section as: - * Row 0: [indicator] [title] - * Rows 1+: content lines indented by 2 spaces (if section is open) - * - * Press Enter or Space to toggle the focused section. - * Press up/down arrow keys to move between sections. - * - * @example - * const accordion = new Accordion([ - * { title: 'System Info', content: 'CPU: 45%\nRAM: 2.1 GB' }, - * { title: 'Network', content: 'eth0: 192.168.1.1' }, - * ]); - */ export class Accordion extends Widget { private _sections: AccordionSection[]; private _openSet: Set; @@ -60,6 +37,8 @@ export class Accordion extends Widget { private _collapseChar: string; private _onToggle?: (index: number, open: boolean) => void; private _focusedIndex: number = 0; + private _animationMs: number; + private _animations: Map = new Map(); constructor( sections: AccordionSection[], @@ -68,88 +47,85 @@ export class Accordion extends Widget { ) { super(style); this.focusable = true; - this._sections = sections; this._multiple = opts.multiple ?? false; - this._expandChar = opts.expandChar ?? (caps.unicode ? '▶' : '>'); - this._collapseChar = opts.collapseChar ?? (caps.unicode ? '▼' : 'v'); + this._expandChar = opts.expandChar ?? (caps.unicode ? '>' : '>'); + this._collapseChar = opts.collapseChar ?? (caps.unicode ? 'v' : 'v'); this._onToggle = opts.onToggle; - - // Initialise open set + this._animationMs = opts.animationMs ?? 250; this._openSet = new Set(); if (sections.length > 0) { const idx = opts.openIndex ?? 0; - if (idx >= 0 && idx < sections.length) { - this._openSet.add(idx); - } + if (idx >= 0 && idx < sections.length) this._openSet.add(idx); } - this._updateHeight(); } - // ── Public API ────────────────────────────────────────────────────── - - /** Open a section by index. No-op if already open or index out of bounds. */ open(index: number): void { if (index < 0 || index >= this._sections.length) return; if (this._openSet.has(index)) return; if (!this._multiple) { - // Fire onToggle for all sections being implicitly closed for (const idx of this._openSet) { + this._startAnimation(idx, false); this._onToggle?.(idx, false); } this._openSet.clear(); } this._openSet.add(index); + this._startAnimation(index, true); this._updateHeight(); this._onToggle?.(index, true); this.markDirty(); } - /** Close a section by index. No-op if already closed. */ close(index: number): void { if (!this._openSet.has(index)) return; this._openSet.delete(index); + this._startAnimation(index, false); this._updateHeight(); this._onToggle?.(index, false); this.markDirty(); } - /** Toggle a section open or closed by index. */ toggle(index: number): void { - if (this._openSet.has(index)) { - this.close(index); - } else { - this.open(index); - } - } - - /** Returns true if the section at the given index is open. */ - isOpen(index: number): boolean { - return this._openSet.has(index); + if (this._openSet.has(index)) this.close(index); + else this.open(index); } - /** Returns the index of the currently keyboard-focused section. */ - getFocusedIndex(): number { - return this._focusedIndex; - } + isOpen(index: number): boolean { return this._openSet.has(index); } + getFocusedIndex(): number { return this._focusedIndex; } - /** Replace all sections and reset open/focus state. */ setSections(sections: AccordionSection[]): void { this._sections = sections; this._openSet.clear(); if (sections.length > 0) this._openSet.add(0); this._focusedIndex = 0; + this._animations.clear(); this._updateHeight(); this.markDirty(); } - // ── Keyboard ──────────────────────────────────────────────────────── + private _startAnimation(index: number, opening: boolean): void { + const section = this._sections[index]; + if (!section) return; + const totalLines = section.content.split('\n').length; + this._animations.set(index, { startTime: Date.now(), opening, totalLines }); + } + + private _getVisibleLines(index: number): number { + const anim = this._animations.get(index); + if (!anim) { + return this._openSet.has(index) ? this._sections[index].content.split('\n').length : 0; + } + const elapsed = Date.now() - anim.startTime; + const progress = Math.min(1, elapsed / this._animationMs); + const visible = anim.opening + ? Math.floor(progress * anim.totalLines) + : Math.floor((1 - progress) * anim.totalLines); + if (progress >= 1) this._animations.delete(index); + return visible; + } - /** - * Handle a key event. Call this from your app's key-routing logic - * when this widget is focused. - */ handleKey(event: KeyEvent): void { switch (event.key.toLowerCase()) { case 'enter': @@ -159,24 +135,15 @@ export class Accordion extends Widget { break; case 'arrowup': case 'up': - if (this._focusedIndex > 0) { - this._focusedIndex--; - this.markDirty(); - } + if (this._focusedIndex > 0) { this._focusedIndex--; this.markDirty(); } break; case 'arrowdown': case 'down': - if (this._focusedIndex < this._sections.length - 1) { - this._focusedIndex++; - this.markDirty(); - } + if (this._focusedIndex < this._sections.length - 1) { this._focusedIndex++; this.markDirty(); } break; } } - // ── Render ────────────────────────────────────────────────────────── - - /** Render all sections with their open/closed state. */ protected _renderSelf(screen: Screen): void { const rect = this._getContentRect(); const { x, y, width, height } = rect; @@ -184,6 +151,7 @@ export class Accordion extends Widget { const attrs = styleToCellAttrs(this._style); let row = 0; + let anyAnimating = false; for (let i = 0; i < this._sections.length; i++) { if (row >= height) break; @@ -191,38 +159,34 @@ export class Accordion extends Widget { const section = this._sections[i]; const open = this._openSet.has(i); const focused = i === this._focusedIndex; + const anim = this._animations.get(i); + if (anim) anyAnimating = true; - // Title row - const indicator = open ? this._collapseChar : this._expandChar; + const progress = anim + ? Math.min(1, (Date.now() - anim.startTime) / this._animationMs) + : (open ? 1 : 0); + const indicator = progress > 0.5 ? this._collapseChar : this._expandChar; const titleLine = indicator + ' ' + section.title; - const titleAttrs = focused - ? { ...attrs, bold: true } - : attrs; + const titleAttrs = focused ? { ...attrs, bold: true } : attrs; screen.writeString(x, y + row, truncate(titleLine, width), titleAttrs); row++; - // Content rows (if open) - if (open) { - const lines = section.content.split('\n'); + const visibleLines = this._getVisibleLines(i); + if (visibleLines > 0) { + const lines = section.content.split('\n').slice(0, visibleLines); for (const line of lines) { if (row >= height) break; - screen.writeString( - x, - y + row, - truncate(' ' + line, width), - attrs, - ); + screen.writeString(x, y + row, truncate(' ' + line, width), attrs); row++; } } } - } - // ── Private ───────────────────────────────────────────────────────── + if (anyAnimating) setTimeout(() => this.markDirty(), 16); + } - /** Recalculate total height based on open sections. */ private _updateHeight(): void { - let total = this._sections.length; // one title row per section + let total = this._sections.length; for (let i = 0; i < this._sections.length; i++) { if (this._openSet.has(i)) { total += this._sections[i].content.split('\n').length; @@ -230,4 +194,4 @@ export class Accordion extends Widget { } this._style.height = total; } -} \ No newline at end of file +}