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 "still working" heartbeat every 30 seconds, 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
59 changes: 44 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 Down Expand Up @@ -32,6 +32,8 @@ export interface SpinnerResult {
readonly isCancelled: boolean;
}

const ACCESSIBLE_HEARTBEAT_MS = 30_000;

const defaultStyleFn: SpinnerOptions['styleFrame'] = (frame) => styleText('magenta', frame);

export const spinner = ({
Expand All @@ -46,8 +48,9 @@ export const spinner = ({
...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 +134,24 @@ 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`);
}
loop = setInterval(() => {
output.write(_message === '' ? 'still working\n' : `still working: ${_message}\n`);
}, ACCESSIBLE_HEARTBEAT_MS);
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 +187,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
106 changes: 106 additions & 0 deletions packages/prompts/test/spinner-accessible.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
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('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