diff --git a/docs/choosing-your-api.md b/docs/choosing-your-api.md index 188821ce..587db4c8 100644 --- a/docs/choosing-your-api.md +++ b/docs/choosing-your-api.md @@ -33,6 +33,29 @@ The Imperative API is the foundation of TermUI. You work directly with classes a - You need the **absolute lowest overhead** and maximum performance. - You prefer **Object-Oriented Programming** and want to manage state in class properties. +### Common use case: Custom widget + +When building a reusable widget (e.g., a progress bar) for others to import: + +```typescript +import { Widget, Style } from '@termuijs/core' + +export class ProgressBar extends Widget { + private progress: number = 0 + + setProgress(value: number) { + this.progress = Math.max(0, Math.min(1, value)) + this.markDirty() // Request re-render + } + + render(buffer) { + const width = this.rect.width + const filled = Math.round(width * this.progress) + const bar = '█'.repeat(filled) + '░'.repeat(width - filled) + buffer.write(0, 0, bar, Style.fg('green')) + } +} + ### Example: Imperative Counter ```typescript diff --git a/packages/widgets/src/Spinner.ts b/packages/widgets/src/Spinner.ts new file mode 100644 index 00000000..ba257919 --- /dev/null +++ b/packages/widgets/src/Spinner.ts @@ -0,0 +1,32 @@ +import { useEffect, useState } from '@termuijs/jsx' +import { caps } from '@termuijs/core' +import { Text } from './Text' + +export interface SpinnerProps { + speed?: number + label?: string + color?: string +} + +const FRAMES_UNICODE = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] +const FRAMES_ASCII = ['|', '/', '-', '\\'] + +export function Spinner({ speed = 80, label = '', color = 'cyan' }: SpinnerProps) { + const [frame, setFrame] = useState(0) + const frames = caps.unicode ? FRAMES_UNICODE : FRAMES_ASCII + + useEffect(() => { + if (caps.noMotion) return + + const interval = setInterval(() => { + setFrame((f) => (f + 1) % frames.length) + }, speed) + + return () => clearInterval(interval) + }, [speed, frames.length]) + + const spinnerChar = frames[frame] + const display = label ? `${spinnerChar} ${label}` : spinnerChar + + return {display} +} \ No newline at end of file diff --git a/packages/widgets/src/index.ts b/packages/widgets/src/index.ts index 316ba500..87114204 100644 --- a/packages/widgets/src/index.ts +++ b/packages/widgets/src/index.ts @@ -310,3 +310,5 @@ export { UnorderedList } from './display/UnorderedList.js'; export type { UnorderedListOptions } from './display/UnorderedList.js'; export { Rule } from './display/Rule.js'; export type { RuleOrientation, RuleOptions } from './display/Rule.js'; +export { Spinner } from './Spinner' +export type { SpinnerProps } from './Spinner' \ No newline at end of file diff --git a/tests/helpers/Spinner.test.ts b/tests/helpers/Spinner.test.ts new file mode 100644 index 00000000..4cc30e7f --- /dev/null +++ b/tests/helpers/Spinner.test.ts @@ -0,0 +1,11 @@ +import { describe, it, expect } from 'bun:test' +import { render } from '@termuijs/testing' +import { Spinner } from '../../packages/widgets/src/Spinner' + +describe('Spinner', () => { + it('renders with label', () => { + const t = render() + expect(t.renderToString()).toContain('Loading') + t.unmount() + }) +}) \ No newline at end of file