From a9a1e20625213b94b113b1f96e7256318023834e Mon Sep 17 00:00:00 2001 From: Tannu Kumari Date: Mon, 22 Jun 2026 19:51:00 +0530 Subject: [PATCH 1/6] feat(widgets): add hover and press animation to Button component - Add startHover/endHover/startPress methods with progress-based transitions - Hover background color and press color-invert effect, animationMs configurable - Auto-detect focus changes in _renderSelf since isFocused is set directly - Respects prefersReducedMotion - Add tests for hover/press behavior Closes #1735 --- packages/widgets/src/input/Button.test.ts | 56 +++++-- packages/widgets/src/input/Button.ts | 184 +++++++++++++++------- 2 files changed, 170 insertions(+), 70 deletions(-) diff --git a/packages/widgets/src/input/Button.test.ts b/packages/widgets/src/input/Button.test.ts index 3bdf6146..f5095091 100644 --- a/packages/widgets/src/input/Button.test.ts +++ b/packages/widgets/src/input/Button.test.ts @@ -1,6 +1,6 @@ -// ───────────────────────────────────────────────────── -// @termuijs/widgets — Tests for Button widget -// ───────────────────────────────────────────────────── +// ───────────────────────────────────────────────────── +// @termuijs/widgets — Tests for Button widget +// ───────────────────────────────────────────────────── import { describe, it, expect, vi, afterEach } from 'vitest'; import { Button } from './Button.js'; @@ -31,14 +31,14 @@ function rowText(screen: Screen, row: number): string { } describe('Button', () => { - // ── 1. Default render ──────────────────────────────────────────────── + // ── 1. Default render ──────────────────────────────────────────────── it('renders label centered in the widget bounds', () => { const { screen } = renderButton('Click'); const contentRow = rowText(screen, 1); expect(contentRow).toContain('Click'); }); - // ── 2. Variant rendering ───────────────────────────────────────────── + // ── 2. Variant rendering ───────────────────────────────────────────── it('primary variant renders with distinct background', () => { const { screen } = renderButton('Submit', { variant: 'primary' }); expect(screen.back[1][1].bg).toBeDefined(); @@ -49,7 +49,7 @@ describe('Button', () => { expect(screen.back[1][1].fg).toBeDefined(); }); - // ── 3. Key handling ────────────────────────────────────────────────── + // ── 3. Key handling ────────────────────────────────────────────────── it('enter key fires onPress callback when not disabled', () => { const onPress = vi.fn(); const { button } = renderButton('Click', { onPress }); @@ -82,7 +82,7 @@ describe('Button', () => { expect(onPress).not.toHaveBeenCalled(); }); - // ── 4. State updates ───────────────────────────────────────────────── + // ── 4. State updates ───────────────────────────────────────────────── it('setLabel updates the displayed label and calls markDirty', () => { const { button, screen } = renderButton('Original'); @@ -107,7 +107,7 @@ describe('Button', () => { expect(markSpy).toHaveBeenCalled(); }); - // ── 5. Key handling uses lowercase ──────────────────────────────────── + // ── 5. Key handling uses lowercase ──────────────────────────────────── it('handleKey uses lowercase event.key: enter', () => { const onPress = vi.fn(); const { button } = renderButton('Click', { onPress }); @@ -140,7 +140,7 @@ describe('Button', () => { expect(onPress).not.toHaveBeenCalled(); }); - // ── 6. Unicode fallback ───────────────────────────────────────────── + // ── 6. Unicode fallback ───────────────────────────────────────────── it('uses ASCII chars when NO_UNICODE=1', async () => { vi.stubEnv('NO_UNICODE', '1'); vi.stubEnv('TERM', ''); @@ -175,4 +175,40 @@ describe('Button', () => { expect(button.isDirty).toBe(false); }); -}); \ No newline at end of file + // ── 7. Hover/press animation ───────────────────────────────────────── + it('startHover schedules a re-render via markDirty after the animation tick', async () => { + const { button } = renderButton('Click'); + const markSpy = vi.spyOn(button, 'markDirty'); + + button.startHover(); + await new Promise(resolve => setTimeout(resolve, 30)); + + expect(markSpy).toHaveBeenCalled(); + }); + + it('endHover clears hover state and calls markDirty', () => { + const { button } = renderButton('Click'); + button.startHover(); + + const markSpy = vi.spyOn(button, 'markDirty'); + button.endHover(); + + expect(markSpy).toHaveBeenCalled(); + }); + + it('focusing the button (isFocused = true) renders with hover background', () => { + const { button, screen } = renderButton('Click'); + + button.isFocused = true; + button.render(screen); + + expect(screen.back[1][1].bg).toBeDefined(); + }); + + it('startPress triggers re-render and does not throw', () => { + const { button } = renderButton('Click'); + + expect(() => button.startPress()).not.toThrow(); + }); +}); + diff --git a/packages/widgets/src/input/Button.ts b/packages/widgets/src/input/Button.ts index 7b02d727..95b0d231 100644 --- a/packages/widgets/src/input/Button.ts +++ b/packages/widgets/src/input/Button.ts @@ -1,7 +1,4 @@ -// ───────────────────────────────────────────────────── -// @termuijs/widgets — Button widget -// ───────────────────────────────────────────────────── - +// @termuijs/widgets - Button widget import { type Screen, type Style, type Color, type KeyEvent, stringWidth, caps, prefersReducedMotion } from '@termuijs/core'; import { timerPoolSubscribe } from '@termuijs/motion'; import { Widget } from '../base/Widget.js'; @@ -14,15 +11,13 @@ export interface ButtonOptions { disabled?: boolean; onPress?: () => void; color?: Color; - /** Show a loading spinner and suppress activation while true (default: false) */ loading?: boolean; - /** Label to display while loading (e.g. "Submitting...") — falls back to the normal label if omitted */ loadingText?: string; + animationMs?: number; } const LOADING_SPINNER = SPINNER_FRAMES.dots; -/** Background colors for each variant */ const BG_COLORS: Record = { default: { type: 'named', name: 'brightBlack' }, primary: { type: 'named', name: 'blue' }, @@ -30,7 +25,6 @@ const BG_COLORS: Record = { ghost: { type: 'named', name: 'brightBlack' }, }; -/** Foreground colors for each variant */ const FG_COLORS: Record = { default: { type: 'named', name: 'white' }, primary: { type: 'named', name: 'white' }, @@ -38,12 +32,13 @@ const FG_COLORS: Record = { ghost: { type: 'named', name: 'white' }, }; -/** - * Button — a pressable button with a label and visual variants. - * - * Renders a button with different visual styles based on variant. - * Handles key events for activation when not disabled. - */ +const HOVER_BG_COLORS: Record = { + default: { type: 'named', name: 'white' }, + primary: { type: 'named', name: 'cyan' }, + danger: { type: 'named', name: 'yellow' }, + ghost: { type: 'named', name: 'white' }, +}; + export class Button extends Widget { private _label: string; private _variant: ButtonVariant; @@ -56,6 +51,11 @@ export class Button extends Widget { private _interval: number; private _startTime?: number; private _timerUnsub?: () => void; + private _animationMs: number; + private _hoverStartTime: number | null = null; + private _pressStartTime: number | null = null; + private _animationTimer: ReturnType | null = null; + private _wasFocused = false; constructor(label: string, style: Partial