Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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;
Comment thread
dreyfus92 marked this conversation as resolved.
Outdated
}

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