From c8e6c3d8fc1c7f4c56627e5906a7329ecb265c28 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:07:47 -0600 Subject: [PATCH] feat(prompts): add accessible mode to `progress` --- .changeset/clever-boats-know.md | 5 + packages/prompts/src/progress-bar.ts | 38 +++++-- .../test/progress-bar-accessible.test.ts | 99 +++++++++++++++++++ 3 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 .changeset/clever-boats-know.md create mode 100644 packages/prompts/test/progress-bar-accessible.test.ts diff --git a/.changeset/clever-boats-know.md b/.changeset/clever-boats-know.md new file mode 100644 index 00000000..14adbce3 --- /dev/null +++ b/.changeset/clever-boats-know.md @@ -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. diff --git a/packages/prompts/src/progress-bar.ts b/packages/prompts/src/progress-bar.ts index 713524cc..c372d212 100644 --- a/packages/prompts/src/progress-bar.ts +++ b/packages/prompts/src/progress-bar.ts @@ -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'; @@ -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); @@ -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; diff --git a/packages/prompts/test/progress-bar-accessible.test.ts b/packages/prompts/test/progress-bar-accessible.test.ts new file mode 100644 index 00000000..c8d49589 --- /dev/null +++ b/packages/prompts/test/progress-bar-accessible.test.ts @@ -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']); + }); +});