Skip to content
Draft
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/clever-boats-know.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clack/prompts": minor
---

Add accessible mode to `progress`: when enabled via the `accessible` option, the global setting, or the `ACCESSIBLE` env var, the drawn bar is replaced by plain lines announcing each 25% milestone.
38 changes: 30 additions & 8 deletions packages/prompts/src/progress-bar.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { styleText } from 'node:util';
import type { State } from '@clack/core';
import { isAccessible, type State } from '@clack/core';
import { unicodeOr } from './common.js';
import { type SpinnerOptions, type SpinnerResult, spinner } from './spinner.js';

Expand All @@ -23,11 +23,15 @@ export function progress({
style = 'heavy',
max: userMax = 100,
size: userSize = 40,
output = process.stdout,
...spinnerOptions
}: ProgressOptions = {}): ProgressResult {
const spin = spinner(spinnerOptions);
const accessible = isAccessible(spinnerOptions.accessible);
const spin = spinner({ ...spinnerOptions, output });
let value = 0;
let previousMessage = '';
let running = false;
let announced = 0;

const max = Math.max(1, userMax);
const size = Math.max(1, userSize);
Expand All @@ -51,21 +55,39 @@ export function progress({
return `${activeStyle(state)(S_PROGRESS_CHAR[style].repeat(active))}${styleText('dim', S_PROGRESS_CHAR[style].repeat(size - active))} ${msg}`;
};

const label = accessible ? (_state: State, msg: string) => msg : drawProgress;

const announce = () => {
if (!accessible || !running || spin.isCancelled) return;
const reached = Math.floor((value * 4) / max) * 25;
if (reached <= announced) return;
announced = reached;
const msg = previousMessage.replace(/\.+$/, '');
output.write(msg === '' ? `${reached}%\n` : `${reached}%: ${msg}\n`);
};

const finish = (end: (msg?: string) => void) => (msg?: string) => {
running = false;
end(msg);
};

const start = (msg = '') => {
previousMessage = msg;
spin.start(drawProgress('initial', msg));
running = true;
spin.start(label('initial', msg));
};
const advance = (step = 1, msg?: string): void => {
value = Math.min(max, step + value);
spin.message(drawProgress('active', msg ?? previousMessage));
spin.message(label('active', msg ?? previousMessage));
previousMessage = msg ?? previousMessage;
announce();
};
return {
start,
stop: spin.stop,
cancel: spin.cancel,
error: spin.error,
clear: spin.clear,
stop: finish(spin.stop),
cancel: finish(spin.cancel),
error: finish(spin.error),
clear: finish(spin.clear),
advance,
get isCancelled() {
return spin.isCancelled;
Expand Down
99 changes: 99 additions & 0 deletions packages/prompts/test/progress-bar-accessible.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import * as prompts from '../src/index.js';
import { MockWritable } from './test-utils.js';

describe('progress (accessible)', () => {
let originalAccessibleEnv: string | undefined;
let output: MockWritable;

beforeEach(() => {
originalAccessibleEnv = process.env.ACCESSIBLE;
delete process.env.ACCESSIBLE;
output = new MockWritable();
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
if (originalAccessibleEnv === undefined) {
delete process.env.ACCESSIBLE;
} else {
process.env.ACCESSIBLE = originalAccessibleEnv;
}
});

test('announces plain milestone lines instead of drawing the bar', () => {
const result = prompts.progress({ output, accessible: true, max: 8 });

result.start('Installing');
result.advance(1, 'fetching');
result.advance(1, 'linking');
vi.advanceTimersByTime(30_000);
result.advance(2);
result.advance(4, 'finishing');
result.stop('Installed');

expect(output.buffer).toEqual([
'Installing\n',
'25%: linking\n',
'still working: linking\n',
'50%: linking\n',
'100%: finishing\n',
'Installed\n',
]);
});

test('announces only the highest milestone crossed and never repeats one', () => {
const result = prompts.progress({ output, accessible: true });

result.start();
result.advance(80);
result.message('still going');
result.advance(-60);
result.advance(60);
result.clear();

expect(output.buffer).toEqual(['75%\n']);
});

test('writes nothing when advanced after the bar has ended', () => {
for (const end of ['stop', 'cancel', 'error', 'clear'] as const) {
output = new MockWritable();
const result = prompts.progress({ output, accessible: true, max: 4 });
result.start('Working');
result[end]('Ended');
const written = [...output.buffer];

result.advance(4);

expect(output.buffer).toEqual(written);
}

output = new MockWritable();
const controller = new AbortController();
const aborted = prompts.progress({
output,
accessible: true,
max: 4,
signal: controller.signal,
});
aborted.start('Working');
controller.abort();
const written = [...output.buffer];

aborted.advance(4);

expect(output.buffer).toEqual(written);
});

test('enabled via ACCESSIBLE env var', () => {
process.env.ACCESSIBLE = '1';
const result = prompts.progress({ output, max: 4 });

result.start('Installing...');
result.advance(1);
result.clear();

expect(output.buffer).toEqual(['Installing\n', '25%: Installing\n']);
});
});
Loading