Skip to content

Commit cbeefcb

Browse files
authored
feat(prompts): add accessible mode to spinner (#596)
1 parent f2bc46b commit cbeefcb

4 files changed

Lines changed: 156 additions & 15 deletions

File tree

‎.changeset/moody-lions-follow.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clack/prompts": minor
3+
---
4+
5+
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.

‎packages/prompts/src/common.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export interface CommonOptions {
7474
output?: Writable;
7575
signal?: AbortSignal;
7676
withGuide?: boolean;
77+
accessible?: boolean;
7778
}
7879

7980
export function formatInstructionFooter(instructions: string[], hasGuide: boolean): string[] {

‎packages/prompts/src/spinner.ts‎

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { styleText } from 'node:util';
2-
import { block, getColumns, settings } from '@clack/core';
2+
import { block, getColumns, isAccessible, settings } from '@clack/core';
33
import { wrapAnsi } from 'fast-wrap-ansi';
44
import { cursor, erase } from 'sisteransi';
55
import {
@@ -32,6 +32,8 @@ export interface SpinnerResult {
3232
readonly isCancelled: boolean;
3333
}
3434

35+
const ACCESSIBLE_HEARTBEAT_MS = 30_000;
36+
3537
const defaultStyleFn: SpinnerOptions['styleFrame'] = (frame) => styleText('magenta', frame);
3638

3739
export const spinner = ({
@@ -46,8 +48,9 @@ export const spinner = ({
4648
...opts
4749
}: SpinnerOptions = {}): SpinnerResult => {
4850
const isCI = isCIFn();
51+
const accessible = isAccessible(opts.accessible);
4952

50-
let unblock: () => void;
53+
let unblock: (() => void) | undefined;
5154
let loop: NodeJS.Timeout;
5255
let isSpinnerActive = false;
5356
let isCancelled = false;
@@ -131,15 +134,24 @@ export const spinner = ({
131134

132135
const start = (msg = ''): void => {
133136
isSpinnerActive = true;
134-
unblock = block({ output });
135137
_message = removeTrailingDots(msg);
136138
_origin = performance.now();
139+
registerHooks();
140+
if (accessible) {
141+
if (_message !== '') {
142+
output.write(`${_message}\n`);
143+
}
144+
loop = setInterval(() => {
145+
output.write(_message === '' ? 'still working\n' : `still working: ${_message}\n`);
146+
}, ACCESSIBLE_HEARTBEAT_MS);
147+
return;
148+
}
149+
unblock = block({ output });
137150
if (hasGuide) {
138151
output.write(`${styleText('gray', S_BAR)}\n`);
139152
}
140153
let frameIndex = 0;
141154
let indicatorTimer = 0;
142-
registerHooks();
143155
loop = setInterval(() => {
144156
if (isCI && _message === _prevMessage) {
145157
return;
@@ -175,23 +187,40 @@ export const spinner = ({
175187
if (!isSpinnerActive) return;
176188
isSpinnerActive = false;
177189
clearInterval(loop);
178-
clearPrevMessage();
179-
const step =
180-
code === 0
181-
? styleText('green', S_STEP_SUBMIT)
182-
: code === 1
183-
? styleText('red', S_STEP_CANCEL)
184-
: styleText('red', S_STEP_ERROR);
190+
if (!accessible) {
191+
clearPrevMessage();
192+
}
185193
_message = msg ?? _message;
186194
if (!silent) {
187-
if (indicator === 'timer') {
188-
output.write(`${step} ${_message} ${formatTimer(_origin)}\n`);
195+
if (accessible) {
196+
const fallback =
197+
code === 1
198+
? (cancelMessage ?? settings.messages.cancel)
199+
: code === 2
200+
? (errorMessage ?? settings.messages.error)
201+
: 'Done';
202+
const finalMessage = _message || fallback;
203+
if (indicator === 'timer') {
204+
output.write(`${finalMessage} ${formatTimer(_origin)}\n`);
205+
} else {
206+
output.write(`${finalMessage}\n`);
207+
}
189208
} else {
190-
output.write(`${step} ${_message}\n`);
209+
const step =
210+
code === 0
211+
? styleText('green', S_STEP_SUBMIT)
212+
: code === 1
213+
? styleText('red', S_STEP_CANCEL)
214+
: styleText('red', S_STEP_ERROR);
215+
if (indicator === 'timer') {
216+
output.write(`${step} ${_message} ${formatTimer(_origin)}\n`);
217+
} else {
218+
output.write(`${step} ${_message}\n`);
219+
}
191220
}
192221
}
193222
clearHooks();
194-
unblock();
223+
unblock?.();
195224
};
196225

197226
const stop = (msg = ''): void => _stop(msg, 0);
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { settings, updateSettings } from '@clack/core';
2+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
3+
import * as prompts from '../src/index.js';
4+
import { MockWritable } from './test-utils.js';
5+
6+
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape codes is the point
7+
const ANSI_REGEX = /\x1b\[/;
8+
9+
describe('spinner (accessible)', () => {
10+
let originalAccessibleEnv: string | undefined;
11+
let originalCIEnv: string | undefined;
12+
let output: MockWritable;
13+
14+
beforeEach(() => {
15+
originalAccessibleEnv = process.env.ACCESSIBLE;
16+
originalCIEnv = process.env.CI;
17+
delete process.env.ACCESSIBLE;
18+
output = new MockWritable();
19+
vi.useFakeTimers();
20+
});
21+
22+
afterEach(() => {
23+
vi.useRealTimers();
24+
vi.restoreAllMocks();
25+
process.env.ACCESSIBLE = originalAccessibleEnv;
26+
process.env.CI = originalCIEnv;
27+
settings.accessible = undefined;
28+
});
29+
30+
test('renders static append-only output with no decorations', () => {
31+
const result = prompts.spinner({ output, accessible: true, withGuide: true });
32+
33+
result.start('Loading');
34+
result.message('Installing');
35+
result.message('Linking');
36+
vi.advanceTimersByTime(30_000);
37+
result.stop('Installed');
38+
vi.advanceTimersByTime(60_000);
39+
40+
expect(output.buffer).toEqual(['Loading\n', 'still working: Linking\n', 'Installed\n']);
41+
expect(output.buffer.join('')).not.toMatch(ANSI_REGEX);
42+
});
43+
44+
test('falls back to plain status words when stopped without a message', () => {
45+
for (const [end, line] of [
46+
['stop', 'Done\n'],
47+
['cancel', 'Canceled\n'],
48+
['error', 'Something went wrong\n'],
49+
] as const) {
50+
output = new MockWritable();
51+
const result = prompts.spinner({ output, accessible: true });
52+
result.start('Working');
53+
result[end]();
54+
expect(output.buffer).toEqual(['Working\n', line]);
55+
}
56+
});
57+
58+
test('abort signal cancels with a plain line', () => {
59+
const controller = new AbortController();
60+
const onCancel = vi.fn();
61+
const result = prompts.spinner({
62+
output,
63+
accessible: true,
64+
signal: controller.signal,
65+
onCancel,
66+
});
67+
68+
result.start('Working');
69+
controller.abort();
70+
71+
expect(output.buffer).toEqual(['Working\n', 'Canceled\n']);
72+
expect(result.isCancelled).toBe(true);
73+
expect(onCancel).toHaveBeenCalledOnce();
74+
});
75+
76+
test('accessible takes precedence over CI mode', () => {
77+
process.env.CI = 'true';
78+
const result = prompts.spinner({ output, accessible: true });
79+
80+
result.start('Loading');
81+
vi.advanceTimersByTime(1000);
82+
result.stop('Done');
83+
84+
expect(output.buffer).toEqual(['Loading\n', 'Done\n']);
85+
});
86+
87+
test('enabled via ACCESSIBLE env var', () => {
88+
process.env.ACCESSIBLE = '1';
89+
const result = prompts.spinner({ output });
90+
91+
result.start('Loading');
92+
result.stop('Done');
93+
94+
expect(output.buffer).toEqual(['Loading\n', 'Done\n']);
95+
});
96+
97+
test('accessible: false option overrides the global setting', () => {
98+
updateSettings({ accessible: true });
99+
const result = prompts.spinner({ output, accessible: false });
100+
101+
result.start('Loading');
102+
result.stop('Done');
103+
104+
expect(output.buffer.join('')).toMatch(ANSI_REGEX);
105+
});
106+
});

0 commit comments

Comments
 (0)