Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/moody-lions-follow.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/prompts/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export interface CommonOptions {
output?: Writable;
signal?: AbortSignal;
withGuide?: boolean;
accessible?: boolean;
}

export function formatInstructionFooter(instructions: string[], hasGuide: boolean): string[] {
Expand Down
66 changes: 51 additions & 15 deletions packages/prompts/src/spinner.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

who is expected to configure this? does it need to be configurable?

we should avoid adding noise to the public API if we don't need to

}

export interface SpinnerResult {
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
121 changes: 121 additions & 0 deletions packages/prompts/test/spinner-accessible.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading