diff --git a/.changeset/moody-lions-follow.md b/.changeset/moody-lions-follow.md new file mode 100644 index 00000000..add0a37f --- /dev/null +++ b/.changeset/moody-lions-follow.md @@ -0,0 +1,5 @@ +--- +"@clack/prompts": minor +--- + +Add accessible mode to `spinner`: when enabled via the `accessible` option, the global setting, or the `ACCESSIBLE` env var, the spinner emits static, append-only, screen-reader friendly output, a plain start line, a periodic "still working" heartbeat configurable via `accessibleInterval`, and a plain final line. Instead of animated in-place repaints. diff --git a/packages/prompts/src/common.ts b/packages/prompts/src/common.ts index e4a0c379..41580fe1 100644 --- a/packages/prompts/src/common.ts +++ b/packages/prompts/src/common.ts @@ -72,6 +72,7 @@ export interface CommonOptions { output?: Writable; signal?: AbortSignal; withGuide?: boolean; + accessible?: boolean; } export function formatInstructionFooter(instructions: string[], hasGuide: boolean): string[] { diff --git a/packages/prompts/src/spinner.ts b/packages/prompts/src/spinner.ts index 618ae427..a84aa25f 100644 --- a/packages/prompts/src/spinner.ts +++ b/packages/prompts/src/spinner.ts @@ -1,5 +1,5 @@ import { styleText } from 'node:util'; -import { block, getColumns, settings } from '@clack/core'; +import { block, getColumns, isAccessible, settings } from '@clack/core'; import { wrapAnsi } from 'fast-wrap-ansi'; import { cursor, erase } from 'sisteransi'; import { @@ -20,6 +20,12 @@ export interface SpinnerOptions extends CommonOptions { frames?: string[]; delay?: number; styleFrame?: (frame: string) => string; + /** + * Milliseconds between "still working" heartbeat lines in accessible mode. + * Set to `0` to disable the heartbeat. + * @default 30_000 + */ + accessibleInterval?: number; } export interface SpinnerResult { @@ -42,12 +48,14 @@ export const spinner = ({ errorMessage, frames = unicode ? ['◒', '◐', '◓', '◑'] : ['•', 'o', 'O', '0'], delay = unicode ? 80 : 120, + accessibleInterval = 30_000, signal, ...opts }: SpinnerOptions = {}): SpinnerResult => { const isCI = isCIFn(); + const accessible = isAccessible(opts.accessible); - let unblock: () => void; + let unblock: (() => void) | undefined; let loop: NodeJS.Timeout; let isSpinnerActive = false; let isCancelled = false; @@ -131,15 +139,26 @@ export const spinner = ({ const start = (msg = ''): void => { isSpinnerActive = true; - unblock = block({ output }); _message = removeTrailingDots(msg); _origin = performance.now(); + registerHooks(); + if (accessible) { + if (_message !== '') { + output.write(`${_message}\n`); + } + if (accessibleInterval > 0) { + loop = setInterval(() => { + output.write(_message === '' ? 'still working\n' : `still working: ${_message}\n`); + }, accessibleInterval); + } + return; + } + unblock = block({ output }); if (hasGuide) { output.write(`${styleText('gray', S_BAR)}\n`); } let frameIndex = 0; let indicatorTimer = 0; - registerHooks(); loop = setInterval(() => { if (isCI && _message === _prevMessage) { return; @@ -175,23 +194,40 @@ export const spinner = ({ if (!isSpinnerActive) return; isSpinnerActive = false; clearInterval(loop); - clearPrevMessage(); - const step = - code === 0 - ? styleText('green', S_STEP_SUBMIT) - : code === 1 - ? styleText('red', S_STEP_CANCEL) - : styleText('red', S_STEP_ERROR); + if (!accessible) { + clearPrevMessage(); + } _message = msg ?? _message; if (!silent) { - if (indicator === 'timer') { - output.write(`${step} ${_message} ${formatTimer(_origin)}\n`); + if (accessible) { + const fallback = + code === 1 + ? (cancelMessage ?? settings.messages.cancel) + : code === 2 + ? (errorMessage ?? settings.messages.error) + : 'Done'; + const finalMessage = _message || fallback; + if (indicator === 'timer') { + output.write(`${finalMessage} ${formatTimer(_origin)}\n`); + } else { + output.write(`${finalMessage}\n`); + } } else { - output.write(`${step} ${_message}\n`); + const step = + code === 0 + ? styleText('green', S_STEP_SUBMIT) + : code === 1 + ? styleText('red', S_STEP_CANCEL) + : styleText('red', S_STEP_ERROR); + if (indicator === 'timer') { + output.write(`${step} ${_message} ${formatTimer(_origin)}\n`); + } else { + output.write(`${step} ${_message}\n`); + } } } clearHooks(); - unblock(); + unblock?.(); }; const stop = (msg = ''): void => _stop(msg, 0); diff --git a/packages/prompts/test/spinner-accessible.test.ts b/packages/prompts/test/spinner-accessible.test.ts new file mode 100644 index 00000000..a651ec32 --- /dev/null +++ b/packages/prompts/test/spinner-accessible.test.ts @@ -0,0 +1,121 @@ +import { settings, updateSettings } from '@clack/core'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import * as prompts from '../src/index.js'; +import { MockWritable } from './test-utils.js'; + +// biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape codes is the point +const ANSI_REGEX = /\x1b\[/; + +describe('spinner (accessible)', () => { + let originalAccessibleEnv: string | undefined; + let originalCIEnv: string | undefined; + let output: MockWritable; + + beforeEach(() => { + originalAccessibleEnv = process.env.ACCESSIBLE; + originalCIEnv = process.env.CI; + delete process.env.ACCESSIBLE; + output = new MockWritable(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + process.env.ACCESSIBLE = originalAccessibleEnv; + process.env.CI = originalCIEnv; + settings.accessible = undefined; + }); + + test('renders static append-only output with no decorations', () => { + const result = prompts.spinner({ output, accessible: true, withGuide: true }); + + result.start('Loading'); + result.message('Installing'); + result.message('Linking'); + vi.advanceTimersByTime(30_000); + result.stop('Installed'); + vi.advanceTimersByTime(60_000); + + expect(output.buffer).toEqual(['Loading\n', 'still working: Linking\n', 'Installed\n']); + expect(output.buffer.join('')).not.toMatch(ANSI_REGEX); + }); + + test('falls back to plain status words when stopped without a message', () => { + for (const [end, line] of [ + ['stop', 'Done\n'], + ['cancel', 'Canceled\n'], + ['error', 'Something went wrong\n'], + ] as const) { + output = new MockWritable(); + const result = prompts.spinner({ output, accessible: true }); + result.start('Working'); + result[end](); + expect(output.buffer).toEqual(['Working\n', line]); + } + }); + + test('accessibleInterval configures the heartbeat and 0 disables it', () => { + const result = prompts.spinner({ output, accessible: true, accessibleInterval: 5000 }); + result.start('a'); + vi.advanceTimersByTime(5000); + result.clear(); + expect(output.buffer).toEqual(['a\n', 'still working: a\n']); + + output = new MockWritable(); + const silent = prompts.spinner({ output, accessible: true, accessibleInterval: 0 }); + silent.start('a'); + vi.advanceTimersByTime(120_000); + silent.clear(); + expect(output.buffer).toEqual(['a\n']); + }); + + test('abort signal cancels with a plain line', () => { + const controller = new AbortController(); + const onCancel = vi.fn(); + const result = prompts.spinner({ + output, + accessible: true, + signal: controller.signal, + onCancel, + }); + + result.start('Working'); + controller.abort(); + + expect(output.buffer).toEqual(['Working\n', 'Canceled\n']); + expect(result.isCancelled).toBe(true); + expect(onCancel).toHaveBeenCalledOnce(); + }); + + test('accessible takes precedence over CI mode', () => { + process.env.CI = 'true'; + const result = prompts.spinner({ output, accessible: true }); + + result.start('Loading'); + vi.advanceTimersByTime(1000); + result.stop('Done'); + + expect(output.buffer).toEqual(['Loading\n', 'Done\n']); + }); + + test('enabled via ACCESSIBLE env var', () => { + process.env.ACCESSIBLE = '1'; + const result = prompts.spinner({ output }); + + result.start('Loading'); + result.stop('Done'); + + expect(output.buffer).toEqual(['Loading\n', 'Done\n']); + }); + + test('accessible: false option overrides the global setting', () => { + updateSettings({ accessible: true }); + const result = prompts.spinner({ output, accessible: false }); + + result.start('Loading'); + result.stop('Done'); + + expect(output.buffer.join('')).toMatch(ANSI_REGEX); + }); +});