Skip to content

feat: add smooth expand/collapse animation for Accordion component#1741

Open
pixeltannu wants to merge 2 commits into
Karanjot786:mainfrom
pixeltannu:feat/accordion-animation
Open

feat: add smooth expand/collapse animation for Accordion component#1741
pixeltannu wants to merge 2 commits into
Karanjot786:mainfrom
pixeltannu:feat/accordion-animation

Conversation

@pixeltannu

@pixeltannu pixeltannu commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Closes #1733

Changes

  • Added animationMs option to AccordionOptions
  • Added AnimationState interface for tracking animation
  • Added _startAnimation() method for expand/collapse effect
  • Added _getVisibleLines() method for progressive line reveal
  • Accordion now smoothly expands line by line when opening
  • Accordion smoothly collapses line by line when closing
  • Indicator rotates from > to v during animation
  • Uses setTimeout with 16ms interval for ~60fps re-render
  • Applied to both packages/ui and packages/widgets

Summary by CodeRabbit

New Features

  • Accordion sections now smoothly animate when expanding or collapsing with visual indicator transitions
  • Toast notifications display with animated entrance effects
  • Both Accordion and Toast components support customizable animation timing via the optional animationMs configuration parameter

@pixeltannu pixeltannu requested a review from Karanjot786 as a code owner June 22, 2026 05:30
@github-actions github-actions Bot added area:widgets @termuijs/widgets area:ui @termuijs/ui type:feature +10 pts. New feature. labels Jun 22, 2026
@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Both Accordion implementations (packages/ui and packages/widgets) and the Toast class gain time-based animations. An AnimationState structure tracks per-section or per-message progress; open/close methods initiate animations instead of toggling visibility directly; _renderSelf renders partial content lines based on elapsed progress and re-schedules itself via setTimeout while any animation is active.

Changes

Accordion and Toast Animation

Layer / File(s) Summary
AnimationState types and animationMs options
packages/ui/src/Accordion.ts, packages/widgets/src/display/Accordion.ts
AccordionOptions gains animationMs?; internal AnimationState structure is added to both packages to track start time, direction, and total line count per section.
open/close/setSections animation initiation
packages/ui/src/Accordion.ts, packages/widgets/src/display/Accordion.ts
openSection/closeSection in packages/ui and open/close/setSections in packages/widgets are updated to call _startAnimation instead of directly toggling visibility; setSections additionally clears in-flight animations.
Animated _renderSelf and _getVisibleLines
packages/ui/src/Accordion.ts, packages/widgets/src/display/Accordion.ts
_renderSelf in both packages computes per-section animation progress, calls _getVisibleLines to limit rendered content rows, updates the indicator character based on progress, and schedules markDirty via setTimeout while any section is animating.
ToastMessage/ToastOptions contracts and icon changes
packages/ui/src/Toast.ts
ToastMessage gains createdAt; ToastOptions gains animationMs; icon glyph constants are updated; _animationMs field and constructor wiring added.
push() timestamp recording and screen-reader announcement
packages/ui/src/Toast.ts
push() records createdAt alongside expireAt; _announceToScreenReader emits a revised OSC payload format.
_getAnimationProgress and animated Toast _renderSelf
packages/ui/src/Toast.ts
_getAnimationProgress computes a 0–1 progress value; _renderSelf truncates each toast label to the progress-computed character count and re-schedules rendering while any visible message is still animating.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • Karanjot786/TermUI#1335: Introduces the initial Accordion widget with open/close/toggle and _renderSelf in packages/widgets/src/display/Accordion.ts — this PR directly extends that logic with AnimationState and progress-based rendering.

Suggested labels

type:feature, area:widgets, level:intermediate

Suggested reviewers

  • Karanjot786

Poem

🐇 Hop, hop, the sections slide~
No more snapping open wide!
Progress ticks from 0 to 1,
Toast and Accordion — both well done.
animationMs sets the pace,
Smooth transitions, full of grace! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is incomplete. It lacks required sections including the 'Description' narrative, 'Which package(s)?' specification, 'Type of Change' checkboxes, and the required 'Related Issue' section heading format. Fill in all required template sections: add a brief description paragraph, explicitly mark 'Which package(s)?' as @termuijs/ui and @termuijs/widgets, check 'Feature' in Type of Change, and format the 'Related Issue' section with proper heading.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the main feature being added: smooth expand/collapse animation for the Accordion component.
Linked Issues check ✅ Passed The PR implementation addresses core requirements from #1733: adds smooth animations with configurable timing (animationMs), progressive line reveal during expand/collapse, indicator rotation, and maintains consistent performance via 16ms re-render intervals.
Out of Scope Changes check ✅ Passed All changes are scoped to implementing accordion animations as specified in #1733. Modifications to AccordionOptions, Toast, and animation state tracking are directly related to the animation feature requirements.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (3)
packages/ui/src/Toast.ts (1)

96-96: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Debounce animation timer scheduling to avoid callback pileups.

_renderSelf can be called frequently; scheduling a new 16ms timeout each call while animating can stack redundant callbacks and increase render churn.

Suggested fix
 export class Toast extends Widget {
@@
   private _animationMs: number;
+  private _animationTimer: ReturnType<typeof setTimeout> | null = null;
@@
-    if (anyAnimating) setTimeout(() => this.markDirty(), 16);
+    if (anyAnimating && this._animationTimer == null) {
+      this._animationTimer = setTimeout(() => {
+        this._animationTimer = null;
+        this.markDirty();
+      }, 16);
+    }
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/Toast.ts` at line 96, The setTimeout call in the anyAnimating
condition is being scheduled on every _renderSelf invocation, which creates
callback pileups when _renderSelf is called frequently. Add debouncing logic to
ensure only one timeout is pending at a time by tracking whether a timeout is
already scheduled. Before scheduling a new setTimeout that calls
this.markDirty(), check if a timeout is already in flight and skip scheduling if
one exists. Store the timeout ID in an instance variable and clear it in the
callback once it executes so subsequent calls can schedule a new timeout when
needed.
packages/ui/src/Accordion.ts (1)

24-28: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

Remove unused visibleLines field from AnimationState.

The visibleLines field is assigned in _startAnimation (line 96) but never read—_getVisibleLines recalculates visibility from progress each frame. Remove the dead field to avoid confusion.

 interface AnimationState {
     startTime: number;
     opening: boolean;
-    visibleLines: number;
     totalLines: number;
 }

And update _startAnimation:

-        this._animations.set(index, {
-            startTime: Date.now(),
-            opening,
-            visibleLines: opening ? 0 : totalLines,
-            totalLines,
-        });
+        this._animations.set(index, {
+            startTime: Date.now(),
+            opening,
+            totalLines,
+        });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/Accordion.ts` around lines 24 - 28, Remove the unused
`visibleLines` field from the `AnimationState` interface since it is assigned in
the `_startAnimation` method but never read—visibility is instead recalculated
from progress in each frame by `_getVisibleLines`. Delete the `visibleLines:
number;` line from the interface definition and remove any assignment to this
field in the `_startAnimation` method to eliminate the dead code.
packages/widgets/src/display/Accordion.ts (1)

52-53: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Remove redundant caps.unicode ternary—both branches are identical.

Both '>' and 'v' are ASCII characters, so the fallback is the same as the primary value. Either use distinct Unicode glyphs with ASCII fallbacks (per coding guidelines), or simply remove the ternary:

-        this._expandChar = opts.expandChar ?? (caps.unicode ? '>' : '>');
-        this._collapseChar = opts.collapseChar ?? (caps.unicode ? 'v' : 'v');
+        this._expandChar = opts.expandChar ?? '>';
+        this._collapseChar = opts.collapseChar ?? 'v';

Alternatively, if you want proper Unicode indicators with ASCII fallback:

-        this._expandChar = opts.expandChar ?? (caps.unicode ? '>' : '>');
-        this._collapseChar = opts.collapseChar ?? (caps.unicode ? 'v' : 'v');
+        this._expandChar = opts.expandChar ?? (caps.unicode ? '▶' : '>');
+        this._collapseChar = opts.collapseChar ?? (caps.unicode ? '▼' : 'v');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/widgets/src/display/Accordion.ts` around lines 52 - 53, The ternary
operators in the assignments for _expandChar and _collapseChar are redundant
because both the caps.unicode true and false branches return the same value.
Either remove the ternary conditions entirely and assign the character directly
to both _expandChar and _collapseChar, or if you want to follow the coding
guidelines and provide proper Unicode indicators with ASCII fallbacks, replace
the assignment values with distinct Unicode glyphs for the true branch while
keeping ASCII characters for the false branch.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/ui/src/Accordion.ts`:
- Around line 101-116: The _getVisibleLines method does not guard against
division by zero when animationMs is zero or negative. Add a guard check after
retrieving the animation object but before calculating elapsed time and
progress. If this._animationMs is less than or equal to zero, immediately return
the final state: return anim.totalLines if anim.opening is true, otherwise
return 0. This ensures instant transitions when animation duration is disabled
while avoiding NaN or Infinity values from the division operation.
- Around line 50-58: In the setItems method, after pruning invalid indices from
the _openSet object, apply the same cleanup logic to the _animations object.
Create an additional loop that iterates through _animations keys and collects
any indices that are greater than or equal to the new items length, then delete
those stale animation state entries. This ensures that when items are removed,
both the open state and animation state are synchronized and do not contain
orphaned references to removed items.

In `@packages/ui/src/Toast.ts`:
- Around line 76-80: The truncation width calculation using Math.min(40, width -
2) can result in a non-positive value for tw when the content width is 2 or
less, causing incorrect behavior in subsequent slice and multiplication
operations. Add a guard check before the tw calculation in the positioning block
to validate that width is greater than 2, and handle the edge case appropriately
by either returning early or ensuring tw maintains a minimum safe value before
it is used in any string slicing or mathematical operations that depend on a
positive value.
- Around line 55-58: In the _announceToScreenReader method, the text parameter
is being interpolated directly into the OSC sequence string without escaping
control characters. Control characters like ESC, BEL, and ST terminators in the
text can break the OSC payload and inject unwanted terminal controls. Escape
these control characters in the text parameter before concatenating it into the
OSC notification sequence that is written to process.stderr.
- Line 89: The type assertion `as any` applied to `COLORS[m.type]` in the cell
attributes passed to the `screen.writeString()` method lacks an inline comment
explaining why it is necessary, which violates the TypeScript strict mode
guidelines. Either remove the `as any` cast entirely if it is not needed, or add
an inline comment immediately after the cast explaining the reason for bypassing
strict typing. The cast is located in the `fg` property configuration within the
attributes object on the line with `screen.writeString(sx, sy + i, label, ...)`.

In `@packages/widgets/src/display/Accordion.ts`:
- Around line 115-127: In the `_getVisibleLines` method, add an early guard
check to handle when `this._animationMs` is zero or less. Before calculating the
progress value using division by `this._animationMs`, add a condition that
returns the appropriate non-animated visibility (either the section's line count
if the section is open, or 0 if closed) when `this._animationMs` is not a
positive number. This prevents division by zero which would result in Infinity
or NaN values that corrupt the progress calculation.
- Around line 188-196: The _updateHeight() method calculates height based on
_openSet membership, but during animations _renderSelf renders different content
determined by _getVisibleLines(), causing a mismatch between claimed height and
rendered height. In _renderSelf(), after rendering content when anyAnimating is
true, call _updateHeight() to ensure the style height stays synchronized with
the actual number of lines being rendered during the animation.

---

Nitpick comments:
In `@packages/ui/src/Accordion.ts`:
- Around line 24-28: Remove the unused `visibleLines` field from the
`AnimationState` interface since it is assigned in the `_startAnimation` method
but never read—visibility is instead recalculated from progress in each frame by
`_getVisibleLines`. Delete the `visibleLines: number;` line from the interface
definition and remove any assignment to this field in the `_startAnimation`
method to eliminate the dead code.

In `@packages/ui/src/Toast.ts`:
- Line 96: The setTimeout call in the anyAnimating condition is being scheduled
on every _renderSelf invocation, which creates callback pileups when _renderSelf
is called frequently. Add debouncing logic to ensure only one timeout is pending
at a time by tracking whether a timeout is already scheduled. Before scheduling
a new setTimeout that calls this.markDirty(), check if a timeout is already in
flight and skip scheduling if one exists. Store the timeout ID in an instance
variable and clear it in the callback once it executes so subsequent calls can
schedule a new timeout when needed.

In `@packages/widgets/src/display/Accordion.ts`:
- Around line 52-53: The ternary operators in the assignments for _expandChar
and _collapseChar are redundant because both the caps.unicode true and false
branches return the same value. Either remove the ternary conditions entirely
and assign the character directly to both _expandChar and _collapseChar, or if
you want to follow the coding guidelines and provide proper Unicode indicators
with ASCII fallbacks, replace the assignment values with distinct Unicode glyphs
for the true branch while keeping ASCII characters for the false branch.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6100b617-2bc6-44b4-ae6a-3eeec4384cb5

📥 Commits

Reviewing files that changed from the base of the PR and between 35c2213 and e33e795.

📒 Files selected for processing (3)
  • packages/ui/src/Accordion.ts
  • packages/ui/src/Toast.ts
  • packages/widgets/src/display/Accordion.ts

Comment on lines 50 to 58
setItems(items: AccordionItem[]): void {
this._items = items;
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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clear stale animation state when items are removed.

setItems prunes _openSet for invalid indices but leaves _animations untouched. If a section is animating when removed, its animation state becomes orphaned and may cause subtle rendering issues.

     setItems(items: AccordionItem[]): void {
         this._items = items;
         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);
+        for (const idx of this._animations.keys()) {
+            if (idx >= items.length) this._animations.delete(idx);
+        }
         this.markDirty();
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
setItems(items: AccordionItem[]): void {
this._items = items;
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();
setItems(items: AccordionItem[]): void {
this._items = items;
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);
for (const idx of this._animations.keys()) {
if (idx >= items.length) this._animations.delete(idx);
}
this.markDirty();
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/Accordion.ts` around lines 50 - 58, In the setItems method,
after pruning invalid indices from the _openSet object, apply the same cleanup
logic to the _animations object. Create an additional loop that iterates through
_animations keys and collects any indices that are greater than or equal to the
new items length, then delete those stale animation state entries. This ensures
that when items are removed, both the open state and animation state are
synchronized and do not contain orphaned references to removed items.

Comment on lines +101 to +116
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard against division by zero when animationMs is zero.

If a user sets animationMs: 0 to disable animation, elapsed / this._animationMs produces NaN (when elapsed is also 0) or Infinity, which propagates through the visibility calculation. Consider treating zero or negative animationMs as an instant transition:

     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;
         }
+        if (this._animationMs <= 0) {
+            this._animations.delete(index);
+            return this._openSet.has(index) ? anim.totalLines : 0;
+        }
         const elapsed = Date.now() - anim.startTime;
         const progress = Math.min(1, elapsed / this._animationMs);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/Accordion.ts` around lines 101 - 116, The _getVisibleLines
method does not guard against division by zero when animationMs is zero or
negative. Add a guard check after retrieving the animation object but before
calculating elapsed time and progress. If this._animationMs is less than or
equal to zero, immediately return the final state: return anim.totalLines if
anim.opening is true, otherwise return 0. This ensures instant transitions when
animation duration is disabled while avoiding NaN or Infinity values from the
division operation.

Comment thread packages/ui/src/Toast.ts
Comment on lines +55 to +58
private _announceToScreenReader(text: string, _type: ToastType): void {
try {
process.stderr.write('\x1b]777;notify;TermUI;[' + text + ']\x07');
} catch { }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Escape control characters before writing OSC notifications.

text is interpolated raw into an OSC sequence. If it contains ESC, BEL, or ST terminators, it can break the payload and inject terminal controls.

Suggested fix
   private _announceToScreenReader(text: string, _type: ToastType): void {
     try {
-      process.stderr.write('\x1b]777;notify;TermUI;[' + text + ']\x07');
+      const safe = text.replace(/[\x07\x1b\x9c]/g, ' ');
+      process.stderr.write(`\x1b]777;notify;TermUI;[${safe}]\x07`);
     } catch { }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private _announceToScreenReader(text: string, _type: ToastType): void {
try {
process.stderr.write('\x1b]777;notify;TermUI;[' + text + ']\x07');
} catch { }
private _announceToScreenReader(text: string, _type: ToastType): void {
try {
const safe = text.replace(/[\x07\x1b\x9c]/g, ' ');
process.stderr.write(`\x1b]777;notify;TermUI;[${safe}]\x07`);
} catch { }
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/Toast.ts` around lines 55 - 58, In the
_announceToScreenReader method, the text parameter is being interpolated
directly into the OSC sequence string without escaping control characters.
Control characters like ESC, BEL, and ST terminators in the text can break the
OSC payload and inject unwanted terminal controls. Escape these control
characters in the text parameter before concatenating it into the OSC
notification sequence that is written to process.stderr.

Comment thread packages/ui/src/Toast.ts
Comment on lines +76 to +80
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard tiny content widths before truncation math.

When width <= 2, tw becomes non-positive, which makes slice(0, tw)/progress * tw behave incorrectly and causes unstable rendering output.

Suggested fix
     const { x, y, width, height } = this._rect;
+    if (width <= 2 || height <= 2) return;
     const visible = this._messages.slice(-this._maxVisible);
     const tw = Math.min(40, width - 2);

Also applies to: 86-88

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/Toast.ts` around lines 76 - 80, The truncation width
calculation using Math.min(40, width - 2) can result in a non-positive value for
tw when the content width is 2 or less, causing incorrect behavior in subsequent
slice and multiplication operations. Add a guard check before the tw calculation
in the positioning block to validate that width is greater than 2, and handle
the edge case appropriately by either returning early or ensuring tw maintains a
minimum safe value before it is used in any string slicing or mathematical
operations that depend on a positive value.

Comment thread packages/ui/src/Toast.ts
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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Remove unannotated as any cast in cell attrs.

This bypasses strict typing and violates the TS guideline for any/type assertions without inline justification.

Suggested fix
-import { type Screen, mergeStyles, defaultStyle, styleToCellAttrs, caps } from '`@termuijs/core`';
+import { type Screen, type Color, mergeStyles, defaultStyle, styleToCellAttrs, caps } from '`@termuijs/core`';
@@
-const COLORS: Record<ToastType, string> = { info: 'cyan', success: 'green', warning: 'yellow', error: 'red' };
+const COLORS: Record<ToastType, Color> = {
+  info: { type: 'named', name: 'cyan' },
+  success: { type: 'named', name: 'green' },
+  warning: { type: 'named', name: 'yellow' },
+  error: { type: 'named', name: 'red' },
+};
@@
-      screen.writeString(sx, sy + i, label, { ...attrs, fg: { type: 'named', name: COLORS[m.type] as any }, bold: true });
+      screen.writeString(sx, sy + i, label, { ...attrs, fg: COLORS[m.type], bold: true });

As per coding guidelines, "**/*.{ts,tsx}: Use TypeScript strict mode. No any without an inline comment explaining why... No type assertions without an inline comment explaining why."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/Toast.ts` at line 89, The type assertion `as any` applied to
`COLORS[m.type]` in the cell attributes passed to the `screen.writeString()`
method lacks an inline comment explaining why it is necessary, which violates
the TypeScript strict mode guidelines. Either remove the `as any` cast entirely
if it is not needed, or add an inline comment immediately after the cast
explaining the reason for bypassing strict typing. The cast is located in the
`fg` property configuration within the attributes object on the line with
`screen.writeString(sx, sy + i, label, ...)`.

Source: Coding guidelines

Comment on lines +115 to +127
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard against division by zero when animationMs is zero.

Same issue as the UI package: if animationMs is 0, division yields NaN or Infinity. Add an early check:

     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;
         }
+        if (this._animationMs <= 0) {
+            this._animations.delete(index);
+            return this._openSet.has(index) ? anim.totalLines : 0;
+        }
         const elapsed = Date.now() - anim.startTime;
         const progress = Math.min(1, elapsed / this._animationMs);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/widgets/src/display/Accordion.ts` around lines 115 - 127, In the
`_getVisibleLines` method, add an early guard check to handle when
`this._animationMs` is zero or less. Before calculating the progress value using
division by `this._animationMs`, add a condition that returns the appropriate
non-animated visibility (either the section's line count if the section is open,
or 0 if closed) when `this._animationMs` is not a positive number. This prevents
division by zero which would result in Infinity or NaN values that corrupt the
progress calculation.

Comment on lines 188 to 196
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;
}
}
this._style.height = total;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Widget height style does not reflect actual rendered height during animation.

_updateHeight() computes height based on _openSet membership, but during animations, _renderSelf renders a different number of lines (via _getVisibleLines). This mismatch can cause layout jumps in parent containers—the widget claims one height while rendering another.

Consider calling _updateHeight() each render frame with the actual visible line count, or updating the height progressively alongside the animation:

     private _updateHeight(): void {
         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;
-            }
+            total += this._getVisibleLines(i);
         }
         this._style.height = total;
     }

Then call this._updateHeight() at the end of _renderSelf when anyAnimating is true to keep the height in sync with rendered content.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/widgets/src/display/Accordion.ts` around lines 188 - 196, The
_updateHeight() method calculates height based on _openSet membership, but
during animations _renderSelf renders different content determined by
_getVisibleLines(), causing a mismatch between claimed height and rendered
height. In _renderSelf(), after rendering content when anyAnimating is true,
call _updateHeight() to ensure the style height stays synchronized with the
actual number of lines being rendered during the animation.

@pixeltannu

Copy link
Copy Markdown
Contributor Author

Hi @Karanjot786 , the CI build failure is a pre-existing issue with the Windows/bun setup (tsup not found in @termuijs/core and vitest not resolving). This is unrelated to my changes which are only in:

  • packages/ui/src/Accordion.ts
  • packages/widgets/src/display/Accordion.ts

Could you please verify and merge? Thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:ui @termuijs/ui area:widgets @termuijs/widgets type:feature +10 pts. New feature.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feature] Add smooth expand/collapse animation for Accordion/Collapsible component

1 participant