From 9312c0876b8a3622b87eaf733058df3c1291ba42 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Tue, 8 Jul 2025 14:04:49 +0100 Subject: [PATCH 1/8] wip: implement JSX package --- examples/basic/spinner-cancel-advanced.ts | 4 +- examples/basic/text-validation.ts | 4 +- package.json | 1 + packages/core/tsconfig.json | 3 +- packages/jsx/CHANGELOG.md | 2 + packages/jsx/LICENSE | 9 ++++ packages/jsx/README.md | 18 +++++++ packages/jsx/build.config.ts | 7 +++ packages/jsx/jsx-runtime.d.ts | 1 + packages/jsx/jsx-runtime.js | 1 + packages/jsx/package.json | 62 +++++++++++++++++++++++ packages/jsx/src/index.ts | 25 +++++++++ packages/jsx/test/jsx.test.tsx | 27 ++++++++++ packages/jsx/tsconfig.json | 8 +++ packages/prompts/test/password.test.ts | 2 +- packages/prompts/tsconfig.json | 3 +- packages/test-utils/LICENSE | 9 ++++ packages/test-utils/README.md | 3 ++ packages/test-utils/package.json | 40 +++++++++++++++ packages/test-utils/src/index.ts | 2 + packages/test-utils/src/mock-readable.ts | 26 ++++++++++ packages/test-utils/src/mock-writable.ts | 14 +++++ packages/test-utils/tsconfig.json | 9 ++++ pnpm-lock.yaml | 15 ++++++ tsconfig.json | 3 +- 25 files changed, 289 insertions(+), 9 deletions(-) create mode 100644 packages/jsx/CHANGELOG.md create mode 100644 packages/jsx/LICENSE create mode 100644 packages/jsx/README.md create mode 100644 packages/jsx/build.config.ts create mode 100644 packages/jsx/jsx-runtime.d.ts create mode 100644 packages/jsx/jsx-runtime.js create mode 100644 packages/jsx/package.json create mode 100644 packages/jsx/src/index.ts create mode 100644 packages/jsx/test/jsx.test.tsx create mode 100644 packages/jsx/tsconfig.json create mode 100644 packages/test-utils/LICENSE create mode 100644 packages/test-utils/README.md create mode 100644 packages/test-utils/package.json create mode 100644 packages/test-utils/src/index.ts create mode 100644 packages/test-utils/src/mock-readable.ts create mode 100644 packages/test-utils/src/mock-writable.ts create mode 100644 packages/test-utils/tsconfig.json diff --git a/examples/basic/spinner-cancel-advanced.ts b/examples/basic/spinner-cancel-advanced.ts index cbf0927d..6cb9a52a 100644 --- a/examples/basic/spinner-cancel-advanced.ts +++ b/examples/basic/spinner-cancel-advanced.ts @@ -81,7 +81,7 @@ async function main() { } catch (error) { // Handle errors but continue if not cancelled if (!processSpinner.isCancelled) { - p.note(`Error processing ${language}: ${error.message}`, 'Error'); + p.note(`Error processing ${language}: ${(error as Error).message}`, 'Error'); } } } @@ -134,7 +134,7 @@ async function main() { } } catch (error) { if (!finalSpinner.isCancelled) { - finalSpinner.stop(`Error during ${action}: ${error.message}`); + finalSpinner.stop(`Error during ${action}: ${(error as Error).message}`); } } } diff --git a/examples/basic/text-validation.ts b/examples/basic/text-validation.ts index 9a637c68..7b928e4c 100644 --- a/examples/basic/text-validation.ts +++ b/examples/basic/text-validation.ts @@ -9,7 +9,7 @@ async function main() { message: 'Enter your name (letters and spaces only)', initialValue: 'John123', // Invalid initial value with numbers validate: (value) => { - if (!/^[a-zA-Z\s]+$/.test(value)) return 'Name can only contain letters and spaces'; + if (!value || !/^[a-zA-Z\s]+$/.test(value)) return 'Name can only contain letters and spaces'; return undefined; }, }); @@ -25,7 +25,7 @@ async function main() { message: 'Enter another name (letters and spaces only)', initialValue: 'John Doe', // Valid initial value validate: (value) => { - if (!/^[a-zA-Z\s]+$/.test(value)) return 'Name can only contain letters and spaces'; + if (!value || !/^[a-zA-Z\s]+$/.test(value)) return 'Name can only contain letters and spaces'; return undefined; }, }); diff --git a/package.json b/package.json index 39950050..30bfcd19 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "dev": "pnpm --filter @example/changesets run start", "format": "biome check --write", "lint": "biome lint --write --unsafe", + "typecheck": "pnpm -r exec tsc --noEmit", "types": "biome lint --write --unsafe", "deps": "pnpm exec knip --production", "test": "pnpm --color -r run test", diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 4082f16a..33e1aaf3 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -1,3 +1,4 @@ { - "extends": "../../tsconfig.json" + "extends": "../../tsconfig.json", + "include": ["./src", "./test"] } diff --git a/packages/jsx/CHANGELOG.md b/packages/jsx/CHANGELOG.md new file mode 100644 index 00000000..b3779c2a --- /dev/null +++ b/packages/jsx/CHANGELOG.md @@ -0,0 +1,2 @@ +# @clack/core + diff --git a/packages/jsx/LICENSE b/packages/jsx/LICENSE new file mode 100644 index 00000000..885bb86b --- /dev/null +++ b/packages/jsx/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) Bombshell Maintainers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/jsx/README.md b/packages/jsx/README.md new file mode 100644 index 00000000..a3c9be3b --- /dev/null +++ b/packages/jsx/README.md @@ -0,0 +1,18 @@ +# `@clack/jsx` + +This package contains JSX support for clack, allowing you to define your +prompts declaratively. + +Each `Prompt` can be rendered as if it were a component: + +```tsx +import {Confirm} from '@clack/jsx'; + +const p = (); + +const name = await p; + +if (isCancel(name)) { + process.exit(0); +} +``` diff --git a/packages/jsx/build.config.ts b/packages/jsx/build.config.ts new file mode 100644 index 00000000..8bd1ad27 --- /dev/null +++ b/packages/jsx/build.config.ts @@ -0,0 +1,7 @@ +import { defineBuildConfig } from 'unbuild'; + +// @see https://github.com/unjs/unbuild +export default defineBuildConfig({ + preset: '../../build.preset', + entries: ['src/index'], +}); diff --git a/packages/jsx/jsx-runtime.d.ts b/packages/jsx/jsx-runtime.d.ts new file mode 100644 index 00000000..28e95ed3 --- /dev/null +++ b/packages/jsx/jsx-runtime.d.ts @@ -0,0 +1 @@ +export { jsx, jsxDEV } from './dist/index.mjs'; diff --git a/packages/jsx/jsx-runtime.js b/packages/jsx/jsx-runtime.js new file mode 100644 index 00000000..28e95ed3 --- /dev/null +++ b/packages/jsx/jsx-runtime.js @@ -0,0 +1 @@ +export { jsx, jsxDEV } from './dist/index.mjs'; diff --git a/packages/jsx/package.json b/packages/jsx/package.json new file mode 100644 index 00000000..3e9018f9 --- /dev/null +++ b/packages/jsx/package.json @@ -0,0 +1,62 @@ +{ + "name": "@clack/jsx", + "version": "1.0.0-alpha.1", + "type": "module", + "main": "./dist/index.mjs", + "module": "./dist/index.mjs", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "./jsx-runtime": "./jsx-runtime.js", + "./jsx-dev-runtime": "./jsx-runtime.js", + "./package.json": "./package.json" + }, + "types": "./dist/index.d.mts", + "repository": { + "type": "git", + "url": "git+https://github.com/bombshell-dev/clack.git", + "directory": "packages/jsx" + }, + "bugs": { + "url": "https://github.com/bombshell-dev/clack/issues" + }, + "homepage": "https://github.com/bombshell-dev/clack/tree/main/packages/jsx#readme", + "files": ["dist", "CHANGELOG.md"], + "keywords": [ + "ask", + "clack", + "cli", + "command-line", + "command", + "input", + "interact", + "interface", + "menu", + "prompt", + "prompts", + "stdin", + "ui", + "jsx", + "ink" + ], + "author": { + "name": "James Garbutt", + "url": "https://github.com/43081j" + }, + "license": "MIT", + "packageManager": "pnpm@9.14.2", + "scripts": { + "build": "unbuild", + "prepack": "pnpm build", + "test": "vitest run" + }, + "dependencies": { + "@clack/prompts": "workspace:*" + }, + "devDependencies": { + "vitest": "^3.1.1", + "@clack/test-utils": "workspace:*" + } +} diff --git a/packages/jsx/src/index.ts b/packages/jsx/src/index.ts new file mode 100644 index 00000000..f6434bf2 --- /dev/null +++ b/packages/jsx/src/index.ts @@ -0,0 +1,25 @@ +import type { ConfirmOptions } from '@clack/prompts'; + +namespace JSX { + export interface IntrinsicElements { + confirm: ConfirmOptions; + } + + export type Element = unknown; +} + +export type { JSX }; + +export function Confirm(_props: JSX.IntrinsicElements['confirm']): JSX.Element { + return 'foo'; +} + +export function jsx( + tag: T, + props: JSX.IntrinsicElements[T], + _key?: string +): JSX.Element { + return 'foo'; +} + +export const jsxDEV = jsx; diff --git a/packages/jsx/test/jsx.test.tsx b/packages/jsx/test/jsx.test.tsx new file mode 100644 index 00000000..369692be --- /dev/null +++ b/packages/jsx/test/jsx.test.tsx @@ -0,0 +1,27 @@ +import { MockReadable, MockWritable } from '@clack/test-utils'; +import { beforeEach, describe, expect, test } from 'vitest'; +import { Confirm, jsx } from '../src/index.js'; + +describe('jsx', () => { + let input: MockReadable; + let output: MockWritable; + + beforeEach(() => { + input = new MockReadable(); + output = new MockWritable(); + }); + + test('can render', async () => { + const result = jsx('confirm', { + message: 'foo?', + input, + output, + }); + expect(result).to.equal('foo'); + }); + + test('can render JSX', () => { + const result = ; + expect(result).to.equal('foo'); + }); +}); diff --git a/packages/jsx/tsconfig.json b/packages/jsx/tsconfig.json new file mode 100644 index 00000000..4d69f21b --- /dev/null +++ b/packages/jsx/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "@clack/jsx" + }, + "include": ["./src", "./test"] +} diff --git a/packages/prompts/test/password.test.ts b/packages/prompts/test/password.test.ts index 9c8c9b7e..3a5479d0 100644 --- a/packages/prompts/test/password.test.ts +++ b/packages/prompts/test/password.test.ts @@ -77,7 +77,7 @@ describe.each(['true', 'false'])('password (isCI = %s)', (isCI) => { const result = prompts.password({ message: 'foo', validate: (value) => { - if (value.length < 2) { + if (!value || value.length < 2) { return 'Password must be at least 2 characters'; } diff --git a/packages/prompts/tsconfig.json b/packages/prompts/tsconfig.json index 4082f16a..33e1aaf3 100644 --- a/packages/prompts/tsconfig.json +++ b/packages/prompts/tsconfig.json @@ -1,3 +1,4 @@ { - "extends": "../../tsconfig.json" + "extends": "../../tsconfig.json", + "include": ["./src", "./test"] } diff --git a/packages/test-utils/LICENSE b/packages/test-utils/LICENSE new file mode 100644 index 00000000..885bb86b --- /dev/null +++ b/packages/test-utils/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) Bombshell Maintainers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/test-utils/README.md b/packages/test-utils/README.md new file mode 100644 index 00000000..722756f8 --- /dev/null +++ b/packages/test-utils/README.md @@ -0,0 +1,3 @@ +# `@clack/test-utils` + +A bunch of useful test utiltiies for use inside clack. diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json new file mode 100644 index 00000000..f422db68 --- /dev/null +++ b/packages/test-utils/package.json @@ -0,0 +1,40 @@ +{ + "name": "@clack/test-utils", + "private": true, + "version": "1.0.0-alpha.1", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "types": "./dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/bombshell-dev/clack.git", + "directory": "packages/test-utils" + }, + "bugs": { + "url": "https://github.com/bombshell-dev/clack/issues" + }, + "homepage": "https://github.com/bombshell-dev/clack/tree/main/packages/test-utils#readme", + "files": ["dist", "CHANGELOG.md"], + "author": { + "name": "James Garbutt", + "url": "https://github.com/43081j" + }, + "license": "MIT", + "packageManager": "pnpm@9.14.2", + "scripts": { + "build": "tsc", + "prepack": "pnpm build" + }, + "dependencies": { + }, + "devDependencies": { + } +} diff --git a/packages/test-utils/src/index.ts b/packages/test-utils/src/index.ts new file mode 100644 index 00000000..fda11f44 --- /dev/null +++ b/packages/test-utils/src/index.ts @@ -0,0 +1,2 @@ +export { MockReadable } from './mock-readable.js'; +export { MockWritable } from './mock-writable.js'; diff --git a/packages/test-utils/src/mock-readable.ts b/packages/test-utils/src/mock-readable.ts new file mode 100644 index 00000000..b08e4879 --- /dev/null +++ b/packages/test-utils/src/mock-readable.ts @@ -0,0 +1,26 @@ +import { Readable } from 'node:stream'; + +export class MockReadable extends Readable { + protected _buffer: unknown[] | null = []; + + _read() { + if (this._buffer === null) { + this.push(null); + return; + } + + for (const val of this._buffer) { + this.push(val); + } + + this._buffer = []; + } + + pushValue(val: unknown): void { + this._buffer?.push(val); + } + + close(): void { + this._buffer = null; + } +} diff --git a/packages/test-utils/src/mock-writable.ts b/packages/test-utils/src/mock-writable.ts new file mode 100644 index 00000000..746b0a0d --- /dev/null +++ b/packages/test-utils/src/mock-writable.ts @@ -0,0 +1,14 @@ +import { Writable } from 'node:stream'; + +export class MockWritable extends Writable { + public buffer: string[] = []; + + _write( + chunk: any, + _encoding: BufferEncoding, + callback: (error?: Error | null | undefined) => void + ): void { + this.buffer.push(chunk.toString()); + callback(); + } +} diff --git a/packages/test-utils/tsconfig.json b/packages/test-utils/tsconfig.json new file mode 100644 index 00000000..e04a918e --- /dev/null +++ b/packages/test-utils/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "outDir": "./dist" + }, + "include": ["./src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2ae3cb1..37f7a585 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,19 @@ importers: specifier: ^8.1.0 version: 8.1.0 + packages/jsx: + dependencies: + '@clack/prompts': + specifier: workspace:* + version: link:../prompts + devDependencies: + '@clack/test-utils': + specifier: workspace:* + version: link:../test-utils + vitest: + specifier: ^3.1.1 + version: 3.1.1(@types/node@18.16.0) + packages/prompts: dependencies: '@clack/core': @@ -99,6 +112,8 @@ importers: specifier: ^0.1.2 version: 0.1.2(vitest@3.1.1(@types/node@18.16.0)) + packages/test-utils: {} + packages: '@ampproject/remapping@2.3.0': diff --git a/tsconfig.json b/tsconfig.json index 50008170..e9f4064f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,5 @@ "paths": { "@clack/core": ["./packages/core/src"] } - }, - "include": ["packages"] + } } From eb79a94edf656b55f4bca09a30d788223aa80d72 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Tue, 8 Jul 2025 14:52:39 +0100 Subject: [PATCH 2/8] wip: add some tests --- packages/jsx/src/index.ts | 29 +++++++++++++++++++++++------ packages/jsx/test/jsx.test.tsx | 20 +++++++++++++++----- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/packages/jsx/src/index.ts b/packages/jsx/src/index.ts index f6434bf2..178da13f 100644 --- a/packages/jsx/src/index.ts +++ b/packages/jsx/src/index.ts @@ -1,25 +1,42 @@ import type { ConfirmOptions } from '@clack/prompts'; +import { confirm } from '@clack/prompts'; namespace JSX { export interface IntrinsicElements { - confirm: ConfirmOptions; } - export type Element = unknown; + export type Element = Promise; } export type { JSX }; -export function Confirm(_props: JSX.IntrinsicElements['confirm']): JSX.Element { - return 'foo'; +export function Confirm(props: ConfirmOptions): ReturnType { + return confirm(props); } -export function jsx( +export type Component = + | typeof Confirm; + +function jsx( tag: T, props: JSX.IntrinsicElements[T], _key?: string +): JSX.Element; +function jsx( + fn: T, + props: Parameters[0], + _key?: string +): JSX.Element; +function jsx( + tagOrFn: string | Component, + props: unknown, + _key?: string ): JSX.Element { - return 'foo'; + if (typeof tagOrFn === 'function') { + return (tagOrFn as (props: unknown) => JSX.Element)(props); + } + return Promise.resolve(null); } +export { jsx }; export const jsxDEV = jsx; diff --git a/packages/jsx/test/jsx.test.tsx b/packages/jsx/test/jsx.test.tsx index 369692be..57d19aa2 100644 --- a/packages/jsx/test/jsx.test.tsx +++ b/packages/jsx/test/jsx.test.tsx @@ -12,16 +12,26 @@ describe('jsx', () => { }); test('can render', async () => { - const result = jsx('confirm', { + const task = jsx(Confirm, { message: 'foo?', input, output, }); - expect(result).to.equal('foo'); + input.emit('keypress', '', { name: 'return' }); + const result = await task; + expect(result).to.equal(true); }); - test('can render JSX', () => { - const result = ; - expect(result).to.equal('foo'); + test('can render JSX', async () => { + const task = (); + input.emit('keypress', '', { name: 'return' }); + const result = await task; + expect(result).to.equal(true); + }); + + test('unknown elements are null', async () => { + const task = jsx('unknown-nonsense' as never, {} as never); + const result = await task; + expect(result).to.equal(null); }); }); From c933bd915317d0132a5be79707de9a709e8e55c9 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Tue, 8 Jul 2025 17:41:03 +0100 Subject: [PATCH 3/8] wip: initial components --- packages/jsx/jsx-runtime.d.ts | 2 +- packages/jsx/jsx-runtime.js | 2 +- packages/jsx/package.json | 1 + packages/jsx/src/components/confirm.ts | 8 + packages/jsx/src/components/note.ts | 31 ++ packages/jsx/src/components/option.ts | 31 ++ packages/jsx/src/components/password.ts | 8 + packages/jsx/src/components/select.ts | 35 ++ packages/jsx/src/components/text.ts | 8 + packages/jsx/src/index.ts | 53 +-- packages/jsx/src/types.ts | 7 + packages/jsx/src/utils.ts | 16 + .../jsx/test/__snapshots__/jsx.test.tsx.snap | 302 ++++++++++++++++++ packages/jsx/test/jsx.test.tsx | 175 +++++++++- packages/jsx/vitest.config.ts | 7 + pnpm-lock.yaml | 3 + 16 files changed, 663 insertions(+), 26 deletions(-) create mode 100644 packages/jsx/src/components/confirm.ts create mode 100644 packages/jsx/src/components/note.ts create mode 100644 packages/jsx/src/components/option.ts create mode 100644 packages/jsx/src/components/password.ts create mode 100644 packages/jsx/src/components/select.ts create mode 100644 packages/jsx/src/components/text.ts create mode 100644 packages/jsx/src/types.ts create mode 100644 packages/jsx/src/utils.ts create mode 100644 packages/jsx/test/__snapshots__/jsx.test.tsx.snap create mode 100644 packages/jsx/vitest.config.ts diff --git a/packages/jsx/jsx-runtime.d.ts b/packages/jsx/jsx-runtime.d.ts index 28e95ed3..98bc230b 100644 --- a/packages/jsx/jsx-runtime.d.ts +++ b/packages/jsx/jsx-runtime.d.ts @@ -1 +1 @@ -export { jsx, jsxDEV } from './dist/index.mjs'; +export * from './dist/index.mjs'; diff --git a/packages/jsx/jsx-runtime.js b/packages/jsx/jsx-runtime.js index 28e95ed3..98bc230b 100644 --- a/packages/jsx/jsx-runtime.js +++ b/packages/jsx/jsx-runtime.js @@ -1 +1 @@ -export { jsx, jsxDEV } from './dist/index.mjs'; +export * from './dist/index.mjs'; diff --git a/packages/jsx/package.json b/packages/jsx/package.json index 3e9018f9..bd6122b1 100644 --- a/packages/jsx/package.json +++ b/packages/jsx/package.json @@ -57,6 +57,7 @@ }, "devDependencies": { "vitest": "^3.1.1", + "vitest-ansi-serializer": "^0.1.2", "@clack/test-utils": "workspace:*" } } diff --git a/packages/jsx/src/components/confirm.ts b/packages/jsx/src/components/confirm.ts new file mode 100644 index 00000000..2293d80e --- /dev/null +++ b/packages/jsx/src/components/confirm.ts @@ -0,0 +1,8 @@ +import type { ConfirmOptions } from '@clack/prompts'; +import { confirm } from '@clack/prompts'; + +export type ConfirmProps = ConfirmOptions; + +export function Confirm(props: ConfirmProps): ReturnType { + return confirm(props); +} diff --git a/packages/jsx/src/components/note.ts b/packages/jsx/src/components/note.ts new file mode 100644 index 00000000..94ac17de --- /dev/null +++ b/packages/jsx/src/components/note.ts @@ -0,0 +1,31 @@ +import type { NoteOptions } from '@clack/prompts'; +import { isCancel, note } from '@clack/prompts'; +import type { JSX } from '../types.js'; +import { resolveChildren } from '../utils.js'; + +export interface NoteProps extends NoteOptions { + children?: JSX.Element[] | JSX.Element | string; + message?: string; + title?: string; +} + +export async function Note(props: NoteProps): Promise { + let message = ''; + + if (props.children) { + const messages: string[] = []; + const children = await resolveChildren(props.children); + for (const child of children) { + // TODO (43081j): handle cancelling of children + if (isCancel(child)) { + continue; + } + messages.push(String(child)); + } + message = messages.join('\n'); + } else if (props.message) { + message = props.message; + } + + note(message, props.title, props); +} diff --git a/packages/jsx/src/components/option.ts b/packages/jsx/src/components/option.ts new file mode 100644 index 00000000..70a56aab --- /dev/null +++ b/packages/jsx/src/components/option.ts @@ -0,0 +1,31 @@ +import { type Option, isCancel } from '@clack/prompts'; +import type { JSX } from '../types.js'; +import { resolveChildren } from '../utils.js'; + +export interface OptionProps { + value: unknown; + children?: JSX.Element | JSX.Element[] | string; +} + +export async function Option(props: OptionProps): Promise> { + let label = ''; + if (props.children) { + const children = await resolveChildren(props.children); + const childStrings: string[] = []; + + for (const child of children) { + if (isCancel(child)) { + continue; + } + childStrings.push(String(child)); + } + + label = childStrings.join('\n'); + } else { + label = String(props.value); + } + return { + value: props.value, + label, + }; +} diff --git a/packages/jsx/src/components/password.ts b/packages/jsx/src/components/password.ts new file mode 100644 index 00000000..a41b3f13 --- /dev/null +++ b/packages/jsx/src/components/password.ts @@ -0,0 +1,8 @@ +import type { PasswordOptions } from '@clack/prompts'; +import { password } from '@clack/prompts'; + +export type PasswordProps = PasswordOptions; + +export function Password(props: PasswordProps): ReturnType { + return password(props); +} diff --git a/packages/jsx/src/components/select.ts b/packages/jsx/src/components/select.ts new file mode 100644 index 00000000..573f158e --- /dev/null +++ b/packages/jsx/src/components/select.ts @@ -0,0 +1,35 @@ +import type { Option, SelectOptions } from '@clack/prompts'; +import { select } from '@clack/prompts'; +import type { JSX } from '../types.js'; +import { resolveChildren } from '../utils.js'; + +export interface SelectProps extends Omit, 'options'> { + children: JSX.Element[] | JSX.Element; +} + +const isOptionLike = (obj: unknown): obj is Option => { + return ( + obj !== null && + typeof obj === 'object' && + Object.hasOwnProperty.call(obj, 'label') && + Object.hasOwnProperty.call(obj, 'value') && + typeof (obj as { label: string }).label === 'string' + ); +}; + +export async function Select(props: SelectProps): ReturnType { + const { children, ...opts } = props; + const options: Option[] = []; + const resolvedChildren = await resolveChildren(props.children); + + for (const child of resolvedChildren) { + if (isOptionLike(child)) { + options.push(child); + } + } + + return select({ + ...opts, + options, + }); +} diff --git a/packages/jsx/src/components/text.ts b/packages/jsx/src/components/text.ts new file mode 100644 index 00000000..1678a9e5 --- /dev/null +++ b/packages/jsx/src/components/text.ts @@ -0,0 +1,8 @@ +import type { TextOptions } from '@clack/prompts'; +import { text } from '@clack/prompts'; + +export type TextProps = TextOptions; + +export function Text(props: TextProps): ReturnType { + return text(props); +} diff --git a/packages/jsx/src/index.ts b/packages/jsx/src/index.ts index 178da13f..5777b67b 100644 --- a/packages/jsx/src/index.ts +++ b/packages/jsx/src/index.ts @@ -1,37 +1,46 @@ -import type { ConfirmOptions } from '@clack/prompts'; -import { confirm } from '@clack/prompts'; - -namespace JSX { - export interface IntrinsicElements { - } - - export type Element = Promise; -} +import { Confirm, type ConfirmProps } from './components/confirm.js'; +import { Note, type NoteProps } from './components/note.js'; +import { Option, type OptionProps } from './components/option.js'; +import { Password, type PasswordProps } from './components/password.js'; +import { Select, type SelectProps } from './components/select.js'; +import { Text, type TextProps } from './components/text.js'; +import type { JSX } from './types.js'; export type { JSX }; +export { + Confirm, + type ConfirmProps, + Note, + type NoteProps, + Text, + type TextProps, + Password, + type PasswordProps, + Option, + type OptionProps, + Select, + type SelectProps, +}; -export function Confirm(props: ConfirmOptions): ReturnType { - return confirm(props); +export function Fragment(props: { children: JSX.Element | JSX.Element[] }): JSX.Element { + return Promise.resolve(props.children); } export type Component = - | typeof Confirm; + | typeof Confirm + | typeof Note + | typeof Text + | typeof Password + | typeof Option + | typeof Select; function jsx( tag: T, props: JSX.IntrinsicElements[T], _key?: string ): JSX.Element; -function jsx( - fn: T, - props: Parameters[0], - _key?: string -): JSX.Element; -function jsx( - tagOrFn: string | Component, - props: unknown, - _key?: string -): JSX.Element { +function jsx(fn: T, props: Parameters[0], _key?: string): JSX.Element; +function jsx(tagOrFn: string | Component, props: unknown, _key?: string): JSX.Element { if (typeof tagOrFn === 'function') { return (tagOrFn as (props: unknown) => JSX.Element)(props); } diff --git a/packages/jsx/src/types.ts b/packages/jsx/src/types.ts new file mode 100644 index 00000000..039efb06 --- /dev/null +++ b/packages/jsx/src/types.ts @@ -0,0 +1,7 @@ +namespace JSX { + export type IntrinsicElements = {}; + + export type Element = Promise; +} + +export type { JSX }; diff --git a/packages/jsx/src/utils.ts b/packages/jsx/src/utils.ts new file mode 100644 index 00000000..b9619986 --- /dev/null +++ b/packages/jsx/src/utils.ts @@ -0,0 +1,16 @@ +import type { JSX } from './types.js'; + +export async function resolveChildren( + children: JSX.Element[] | JSX.Element | string +): Promise { + const arr = Array.isArray(children) ? children : [children]; + const results: unknown[] = []; + + for (const child of arr) { + const result = await child; + + results.push(result); + } + + return results; +} diff --git a/packages/jsx/test/__snapshots__/jsx.test.tsx.snap b/packages/jsx/test/__snapshots__/jsx.test.tsx.snap new file mode 100644 index 00000000..ca3ee16e --- /dev/null +++ b/packages/jsx/test/__snapshots__/jsx.test.tsx.snap @@ -0,0 +1,302 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`jsx > Confirm > can set active text 1`] = ` +[ + "", + "โ”‚ +โ—† foo? +โ”‚ โ— DO IT / โ—‹ No +โ”” +", + "", + "", + "", + "โ—‡ foo? +โ”‚ DO IT", + " +", + "", +] +`; + +exports[`jsx > Confirm > can set inactive text 1`] = ` +[ + "", + "โ”‚ +โ—† foo? +โ”‚ โ— Yes / โ—‹ DONT DO IT +โ”” +", + "", + "", + "", + "โ—‡ foo? +โ”‚ Yes", + " +", + "", +] +`; + +exports[`jsx > Confirm > can set message 1`] = ` +[ + "", + "โ”‚ +โ—† foo? +โ”‚ โ— Yes / โ—‹ No +โ”” +", + "", + "", + "", + "โ—‡ foo? +โ”‚ Yes", + " +", + "", +] +`; + +exports[`jsx > Note > can render children as message 1`] = ` +[ + "โ”‚ +โ—‡  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ โ”‚ +โ”‚ a message โ”‚ +โ”‚ โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ +", +] +`; + +exports[`jsx > Note > can render complex results as message 1`] = ` +[ + "", + "โ”‚ +โ—† say yes +โ”‚ โ— Yes / โ—‹ No +โ”” +", + "", + "", + "", + "โ—‡ say yes +โ”‚ Yes", + " +", + "", + "โ”‚ +โ—‡  โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ โ”‚ +โ”‚ true โ”‚ +โ”‚ โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ +", +] +`; + +exports[`jsx > Note > can render multiple children as message 1`] = ` +[ + "", + "โ”‚ +โ—† say yes +โ”‚ โ— Yes / โ—‹ No +โ”” +", + "", + "โ”‚ +โ—† say yes again +โ”‚ โ— Yes / โ—‹ No +โ”” +", + "", + "", + "", + "โ—‡ say yes +โ”‚ Yes", + " +", + "", + "", + "", + "", + "โ—‡ say yes again +โ”‚ Yes", + " +", + "", + "โ”‚ +โ—‡  โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ โ”‚ +โ”‚ true โ”‚ +โ”‚ true โ”‚ +โ”‚ โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ +", +] +`; + +exports[`jsx > Note > can render string message 1`] = ` +[ + "โ”‚ +โ—‡  โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ โ”‚ +โ”‚ foo โ”‚ +โ”‚ โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ +", +] +`; + +exports[`jsx > Password > can set custom mask 1`] = ` +[ + "", + "โ”‚ +โ—† foo +โ”‚ _ +โ”” +", + "", + "", + "", + "โ”‚ !_", + "", + "", + "", + "", + "โ”‚ !!_", + "", + "", + "", + "", + "โ—‡ foo +โ”‚ !!", + " +", + "", +] +`; + +exports[`jsx > Password > renders password input 1`] = ` +[ + "", + "โ”‚ +โ—† foo +โ”‚ _ +โ”” +", + "", + "", + "", + "โ—‡ foo +โ”‚", + " +", + "", +] +`; + +exports[`jsx > Password > renders user input 1`] = ` +[ + "", + "โ”‚ +โ—† foo +โ”‚ _ +โ”” +", + "", + "", + "", + "โ”‚ โ–ช_", + "", + "", + "", + "", + "โ”‚ โ–ชโ–ช_", + "", + "", + "", + "", + "โ—‡ foo +โ”‚ โ–ชโ–ช", + " +", + "", +] +`; + +exports[`jsx > Text > can set default value 1`] = ` +[ + "", + "โ”‚ +โ—† foo +โ”‚ _ +โ”” +", + "", + "", + "", + "โ—‡ foo +โ”‚ bar", + " +", + "", +] +`; + +exports[`jsx > Text > can set initial value 1`] = ` +[ + "", + "โ”‚ +โ—† foo +โ”‚ barโ–ˆ +โ”” +", + "", + "", + "", + "โ—‡ foo +โ”‚ bar", + " +", + "", +] +`; + +exports[`jsx > Text > can set placeholder 1`] = ` +[ + "", + "โ”‚ +โ—† foo +โ”‚ bar +โ”” +", + "", + "", + "", + "โ—‡ foo +โ”‚", + " +", + "", +] +`; + +exports[`jsx > Text > renders text input 1`] = ` +[ + "", + "โ”‚ +โ—† foo +โ”‚ _ +โ”” +", + "", + "", + "", + "โ—‡ foo +โ”‚", + " +", + "", +] +`; diff --git a/packages/jsx/test/jsx.test.tsx b/packages/jsx/test/jsx.test.tsx index 57d19aa2..d7cab1e8 100644 --- a/packages/jsx/test/jsx.test.tsx +++ b/packages/jsx/test/jsx.test.tsx @@ -1,6 +1,6 @@ import { MockReadable, MockWritable } from '@clack/test-utils'; import { beforeEach, describe, expect, test } from 'vitest'; -import { Confirm, jsx } from '../src/index.js'; +import { Confirm, Note, Option, Password, Select, Text, jsx } from '../src/index.js'; describe('jsx', () => { let input: MockReadable; @@ -23,7 +23,7 @@ describe('jsx', () => { }); test('can render JSX', async () => { - const task = (); + const task = ; input.emit('keypress', '', { name: 'return' }); const result = await task; expect(result).to.equal(true); @@ -34,4 +34,175 @@ describe('jsx', () => { const result = await task; expect(result).to.equal(null); }); + + describe('Confirm', () => { + test('can set message', async () => { + const task = ; + input.emit('keypress', '', { name: 'return' }); + const result = await task; + expect(result).to.equal(true); + expect(output.buffer).toMatchSnapshot(); + }); + + test('can set active text', async () => { + const task = ; + input.emit('keypress', '', { name: 'return' }); + const result = await task; + expect(result).to.equal(true); + expect(output.buffer).toMatchSnapshot(); + }); + + test('can set inactive text', async () => { + const task = ; + input.emit('keypress', '', { name: 'return' }); + const result = await task; + expect(result).to.equal(true); + expect(output.buffer).toMatchSnapshot(); + }); + }); + + describe('Note', () => { + test('can render string message', async () => { + const task = ; + await task; + + expect(output.buffer).toMatchSnapshot(); + }); + + test('can render children as message', async () => { + const task = a message; + await task; + + expect(output.buffer).toMatchSnapshot(); + }); + + test('can render complex results as message', async () => { + const task = ( + + + + ); + input.emit('keypress', '', { name: 'return' }); + await task; + + expect(output.buffer).toMatchSnapshot(); + }); + + test('can render multiple children as message', async () => { + const task = ( + + + + + ); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '', { name: 'return' }); + await task; + + expect(output.buffer).toMatchSnapshot(); + }); + }); + + describe('Text', () => { + test('renders text input', async () => { + const task = ; + + input.emit('keypress', '', { name: 'return' }); + + const result = await task; + + expect(result).to.equal(''); + expect(output.buffer).toMatchSnapshot(); + }); + + test('can set placeholder', async () => { + const task = ; + + input.emit('keypress', '', { name: 'return' }); + + const result = await task; + + expect(result).to.equal(''); + expect(output.buffer).toMatchSnapshot(); + }); + + test('can set default value', async () => { + const task = ; + + input.emit('keypress', '', { name: 'return' }); + + const result = await task; + + expect(result).to.equal('bar'); + expect(output.buffer).toMatchSnapshot(); + }); + + test('can set initial value', async () => { + const task = ; + + input.emit('keypress', '', { name: 'return' }); + + const result = await task; + + expect(result).to.equal('bar'); + expect(output.buffer).toMatchSnapshot(); + }); + }); + + describe('Password', () => { + test('renders password input', async () => { + const task = ; + + input.emit('keypress', '', { name: 'return' }); + + const result = await task; + + expect(result).to.equal(undefined); + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders user input', async () => { + const task = ; + + input.emit('keypress', 'a', { name: 'a' }); + input.emit('keypress', 'b', { name: 'b' }); + input.emit('keypress', '', { name: 'return' }); + + const result = await task; + + expect(result).to.equal('ab'); + expect(output.buffer).toMatchSnapshot(); + }); + + test('can set custom mask', async () => { + const task = ; + + input.emit('keypress', 'a', { name: 'a' }); + input.emit('keypress', 'b', { name: 'b' }); + input.emit('keypress', '', { name: 'return' }); + + const result = await task; + + expect(result).to.equal('ab'); + expect(output.buffer).toMatchSnapshot(); + }); + }); + + describe('Select', () => { + test('renders options', async () => { + const task = ( + + ); + + input.emit('keypress', '', { name: 'return' }); + + const result = await task; + + expect(result).to.equal(303); + expect(output.buffer).toMatchSnapshot(); + }); + }); }); diff --git a/packages/jsx/vitest.config.ts b/packages/jsx/vitest.config.ts new file mode 100644 index 00000000..bfd9650d --- /dev/null +++ b/packages/jsx/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + snapshotSerializers: ['vitest-ansi-serializer'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37f7a585..30d75fd7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: vitest: specifier: ^3.1.1 version: 3.1.1(@types/node@18.16.0) + vitest-ansi-serializer: + specifier: ^0.1.2 + version: 0.1.2(vitest@3.1.1(@types/node@18.16.0)) packages/prompts: dependencies: From c26349e95604d30200a9460ebb8cd648927d54ae Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Tue, 8 Jul 2025 23:05:46 +0100 Subject: [PATCH 4/8] wip: add a bunch more components --- packages/jsx/src/components/option.ts | 31 +- packages/jsx/src/components/select.ts | 10 +- packages/jsx/src/types.ts | 2 +- .../jsx/test/__snapshots__/jsx.test.tsx.snap | 302 ------------------ .../__snapshots__/confirm.test.tsx.snap | 58 ++++ .../__snapshots__/note.test.tsx.snap | 92 ++++++ .../__snapshots__/password.test.tsx.snap | 78 +++++ .../__snapshots__/select.test.tsx.snap | 61 ++++ .../__snapshots__/text.test.tsx.snap | 77 +++++ packages/jsx/test/components/confirm.test.tsx | 37 +++ packages/jsx/test/components/note.test.tsx | 53 +++ .../jsx/test/components/password.test.tsx | 50 +++ packages/jsx/test/components/select.test.tsx | 74 +++++ packages/jsx/test/components/text.test.tsx | 57 ++++ packages/jsx/test/jsx.test.tsx | 173 +--------- 15 files changed, 657 insertions(+), 498 deletions(-) delete mode 100644 packages/jsx/test/__snapshots__/jsx.test.tsx.snap create mode 100644 packages/jsx/test/components/__snapshots__/confirm.test.tsx.snap create mode 100644 packages/jsx/test/components/__snapshots__/note.test.tsx.snap create mode 100644 packages/jsx/test/components/__snapshots__/password.test.tsx.snap create mode 100644 packages/jsx/test/components/__snapshots__/select.test.tsx.snap create mode 100644 packages/jsx/test/components/__snapshots__/text.test.tsx.snap create mode 100644 packages/jsx/test/components/confirm.test.tsx create mode 100644 packages/jsx/test/components/note.test.tsx create mode 100644 packages/jsx/test/components/password.test.tsx create mode 100644 packages/jsx/test/components/select.test.tsx create mode 100644 packages/jsx/test/components/text.test.tsx diff --git a/packages/jsx/src/components/option.ts b/packages/jsx/src/components/option.ts index 70a56aab..a49b839b 100644 --- a/packages/jsx/src/components/option.ts +++ b/packages/jsx/src/components/option.ts @@ -1,31 +1,32 @@ -import { type Option, isCancel } from '@clack/prompts'; +import { type Option as PromptOption, isCancel } from '@clack/prompts'; import type { JSX } from '../types.js'; import { resolveChildren } from '../utils.js'; -export interface OptionProps { - value: unknown; +export interface OptionProps { + value: T; + hint?: string; children?: JSX.Element | JSX.Element[] | string; } -export async function Option(props: OptionProps): Promise> { - let label = ''; - if (props.children) { - const children = await resolveChildren(props.children); +export async function Option(props: OptionProps): Promise> { + const { children, ...opts } = props; + + if (children) { + const resolvedChildren = await resolveChildren(children); const childStrings: string[] = []; - for (const child of children) { + for (const child of resolvedChildren) { if (isCancel(child)) { continue; } childStrings.push(String(child)); } - label = childStrings.join('\n'); - } else { - label = String(props.value); + return { + ...opts, + label: childStrings.join('\n'), + } as PromptOption; } - return { - value: props.value, - label, - }; + + return opts as PromptOption; } diff --git a/packages/jsx/src/components/select.ts b/packages/jsx/src/components/select.ts index 573f158e..08c2c102 100644 --- a/packages/jsx/src/components/select.ts +++ b/packages/jsx/src/components/select.ts @@ -4,17 +4,11 @@ import type { JSX } from '../types.js'; import { resolveChildren } from '../utils.js'; export interface SelectProps extends Omit, 'options'> { - children: JSX.Element[] | JSX.Element; + children: JSX.Element[] | JSX.Element | string; } const isOptionLike = (obj: unknown): obj is Option => { - return ( - obj !== null && - typeof obj === 'object' && - Object.hasOwnProperty.call(obj, 'label') && - Object.hasOwnProperty.call(obj, 'value') && - typeof (obj as { label: string }).label === 'string' - ); + return obj !== null && typeof obj === 'object' && Object.hasOwnProperty.call(obj, 'value'); }; export async function Select(props: SelectProps): ReturnType { diff --git a/packages/jsx/src/types.ts b/packages/jsx/src/types.ts index 039efb06..70519daa 100644 --- a/packages/jsx/src/types.ts +++ b/packages/jsx/src/types.ts @@ -1,5 +1,5 @@ namespace JSX { - export type IntrinsicElements = {}; + export type IntrinsicElements = never; export type Element = Promise; } diff --git a/packages/jsx/test/__snapshots__/jsx.test.tsx.snap b/packages/jsx/test/__snapshots__/jsx.test.tsx.snap deleted file mode 100644 index ca3ee16e..00000000 --- a/packages/jsx/test/__snapshots__/jsx.test.tsx.snap +++ /dev/null @@ -1,302 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`jsx > Confirm > can set active text 1`] = ` -[ - "", - "โ”‚ -โ—† foo? -โ”‚ โ— DO IT / โ—‹ No -โ”” -", - "", - "", - "", - "โ—‡ foo? -โ”‚ DO IT", - " -", - "", -] -`; - -exports[`jsx > Confirm > can set inactive text 1`] = ` -[ - "", - "โ”‚ -โ—† foo? -โ”‚ โ— Yes / โ—‹ DONT DO IT -โ”” -", - "", - "", - "", - "โ—‡ foo? -โ”‚ Yes", - " -", - "", -] -`; - -exports[`jsx > Confirm > can set message 1`] = ` -[ - "", - "โ”‚ -โ—† foo? -โ”‚ โ— Yes / โ—‹ No -โ”” -", - "", - "", - "", - "โ—‡ foo? -โ”‚ Yes", - " -", - "", -] -`; - -exports[`jsx > Note > can render children as message 1`] = ` -[ - "โ”‚ -โ—‡  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ -โ”‚ โ”‚ -โ”‚ a message โ”‚ -โ”‚ โ”‚ -โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ -", -] -`; - -exports[`jsx > Note > can render complex results as message 1`] = ` -[ - "", - "โ”‚ -โ—† say yes -โ”‚ โ— Yes / โ—‹ No -โ”” -", - "", - "", - "", - "โ—‡ say yes -โ”‚ Yes", - " -", - "", - "โ”‚ -โ—‡  โ”€โ”€โ”€โ”€โ”€โ•ฎ -โ”‚ โ”‚ -โ”‚ true โ”‚ -โ”‚ โ”‚ -โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ -", -] -`; - -exports[`jsx > Note > can render multiple children as message 1`] = ` -[ - "", - "โ”‚ -โ—† say yes -โ”‚ โ— Yes / โ—‹ No -โ”” -", - "", - "โ”‚ -โ—† say yes again -โ”‚ โ— Yes / โ—‹ No -โ”” -", - "", - "", - "", - "โ—‡ say yes -โ”‚ Yes", - " -", - "", - "", - "", - "", - "โ—‡ say yes again -โ”‚ Yes", - " -", - "", - "โ”‚ -โ—‡  โ”€โ”€โ”€โ”€โ”€โ•ฎ -โ”‚ โ”‚ -โ”‚ true โ”‚ -โ”‚ true โ”‚ -โ”‚ โ”‚ -โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ -", -] -`; - -exports[`jsx > Note > can render string message 1`] = ` -[ - "โ”‚ -โ—‡  โ”€โ”€โ”€โ”€โ•ฎ -โ”‚ โ”‚ -โ”‚ foo โ”‚ -โ”‚ โ”‚ -โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ -", -] -`; - -exports[`jsx > Password > can set custom mask 1`] = ` -[ - "", - "โ”‚ -โ—† foo -โ”‚ _ -โ”” -", - "", - "", - "", - "โ”‚ !_", - "", - "", - "", - "", - "โ”‚ !!_", - "", - "", - "", - "", - "โ—‡ foo -โ”‚ !!", - " -", - "", -] -`; - -exports[`jsx > Password > renders password input 1`] = ` -[ - "", - "โ”‚ -โ—† foo -โ”‚ _ -โ”” -", - "", - "", - "", - "โ—‡ foo -โ”‚", - " -", - "", -] -`; - -exports[`jsx > Password > renders user input 1`] = ` -[ - "", - "โ”‚ -โ—† foo -โ”‚ _ -โ”” -", - "", - "", - "", - "โ”‚ โ–ช_", - "", - "", - "", - "", - "โ”‚ โ–ชโ–ช_", - "", - "", - "", - "", - "โ—‡ foo -โ”‚ โ–ชโ–ช", - " -", - "", -] -`; - -exports[`jsx > Text > can set default value 1`] = ` -[ - "", - "โ”‚ -โ—† foo -โ”‚ _ -โ”” -", - "", - "", - "", - "โ—‡ foo -โ”‚ bar", - " -", - "", -] -`; - -exports[`jsx > Text > can set initial value 1`] = ` -[ - "", - "โ”‚ -โ—† foo -โ”‚ barโ–ˆ -โ”” -", - "", - "", - "", - "โ—‡ foo -โ”‚ bar", - " -", - "", -] -`; - -exports[`jsx > Text > can set placeholder 1`] = ` -[ - "", - "โ”‚ -โ—† foo -โ”‚ bar -โ”” -", - "", - "", - "", - "โ—‡ foo -โ”‚", - " -", - "", -] -`; - -exports[`jsx > Text > renders text input 1`] = ` -[ - "", - "โ”‚ -โ—† foo -โ”‚ _ -โ”” -", - "", - "", - "", - "โ—‡ foo -โ”‚", - " -", - "", -] -`; diff --git a/packages/jsx/test/components/__snapshots__/confirm.test.tsx.snap b/packages/jsx/test/components/__snapshots__/confirm.test.tsx.snap new file mode 100644 index 00000000..896f4a74 --- /dev/null +++ b/packages/jsx/test/components/__snapshots__/confirm.test.tsx.snap @@ -0,0 +1,58 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Confirm > can set active text 1`] = ` +[ + "", + "โ”‚ +โ—† foo? +โ”‚ โ— DO IT / โ—‹ No +โ”” +", + "", + "", + "", + "โ—‡ foo? +โ”‚ DO IT", + " +", + "", +] +`; + +exports[`Confirm > can set inactive text 1`] = ` +[ + "", + "โ”‚ +โ—† foo? +โ”‚ โ— Yes / โ—‹ DONT DO IT +โ”” +", + "", + "", + "", + "โ—‡ foo? +โ”‚ Yes", + " +", + "", +] +`; + +exports[`Confirm > can set message 1`] = ` +[ + "", + "โ”‚ +โ—† foo? +โ”‚ โ— Yes / โ—‹ No +โ”” +", + "", + "", + "", + "โ—‡ foo? +โ”‚ Yes", + " +", + "", +] +`; diff --git a/packages/jsx/test/components/__snapshots__/note.test.tsx.snap b/packages/jsx/test/components/__snapshots__/note.test.tsx.snap new file mode 100644 index 00000000..e67dcbb8 --- /dev/null +++ b/packages/jsx/test/components/__snapshots__/note.test.tsx.snap @@ -0,0 +1,92 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Note > can render children as message 1`] = ` +[ + "โ”‚ +โ—‡  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ โ”‚ +โ”‚ a message โ”‚ +โ”‚ โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ +", +] +`; + +exports[`Note > can render complex results as message 1`] = ` +[ + "", + "โ”‚ +โ—† say yes +โ”‚ โ— Yes / โ—‹ No +โ”” +", + "", + "", + "", + "โ—‡ say yes +โ”‚ Yes", + " +", + "", + "โ”‚ +โ—‡  โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ โ”‚ +โ”‚ true โ”‚ +โ”‚ โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ +", +] +`; + +exports[`Note > can render multiple children as message 1`] = ` +[ + "", + "โ”‚ +โ—† say yes +โ”‚ โ— Yes / โ—‹ No +โ”” +", + "", + "โ”‚ +โ—† say yes again +โ”‚ โ— Yes / โ—‹ No +โ”” +", + "", + "", + "", + "โ—‡ say yes +โ”‚ Yes", + " +", + "", + "", + "", + "", + "โ—‡ say yes again +โ”‚ Yes", + " +", + "", + "โ”‚ +โ—‡  โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ โ”‚ +โ”‚ true โ”‚ +โ”‚ true โ”‚ +โ”‚ โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ +", +] +`; + +exports[`Note > can render string message 1`] = ` +[ + "โ”‚ +โ—‡  โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ โ”‚ +โ”‚ foo โ”‚ +โ”‚ โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ +", +] +`; diff --git a/packages/jsx/test/components/__snapshots__/password.test.tsx.snap b/packages/jsx/test/components/__snapshots__/password.test.tsx.snap new file mode 100644 index 00000000..ca36fd81 --- /dev/null +++ b/packages/jsx/test/components/__snapshots__/password.test.tsx.snap @@ -0,0 +1,78 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Password > can set custom mask 1`] = ` +[ + "", + "โ”‚ +โ—† foo +โ”‚ _ +โ”” +", + "", + "", + "", + "โ”‚ !_", + "", + "", + "", + "", + "โ”‚ !!_", + "", + "", + "", + "", + "โ—‡ foo +โ”‚ !!", + " +", + "", +] +`; + +exports[`Password > renders password input 1`] = ` +[ + "", + "โ”‚ +โ—† foo +โ”‚ _ +โ”” +", + "", + "", + "", + "โ—‡ foo +โ”‚", + " +", + "", +] +`; + +exports[`Password > renders user input 1`] = ` +[ + "", + "โ”‚ +โ—† foo +โ”‚ _ +โ”” +", + "", + "", + "", + "โ”‚ โ–ช_", + "", + "", + "", + "", + "โ”‚ โ–ชโ–ช_", + "", + "", + "", + "", + "โ—‡ foo +โ”‚ โ–ชโ–ช", + " +", + "", +] +`; diff --git a/packages/jsx/test/components/__snapshots__/select.test.tsx.snap b/packages/jsx/test/components/__snapshots__/select.test.tsx.snap new file mode 100644 index 00000000..f5e77f75 --- /dev/null +++ b/packages/jsx/test/components/__snapshots__/select.test.tsx.snap @@ -0,0 +1,61 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Select > renders options 1`] = ` +[ + "", + "โ”‚ +โ—† foo +โ”‚ โ— opt0 +โ”‚ โ—‹ opt1 +โ”” +", + "", + "", + "", + "โ—‡ foo +โ”‚ opt0", + " +", + "", +] +`; + +exports[`Select > renders options with hints 1`] = ` +[ + "", + "โ”‚ +โ—† foo +โ”‚ โ— Three o three (hint one) +โ”‚ โ—‹ Eight o eight +โ”” +", + "", + "", + "", + "โ—‡ foo +โ”‚ Three o three", + " +", + "", +] +`; + +exports[`Select > renders options with labels 1`] = ` +[ + "", + "โ”‚ +โ—† foo +โ”‚ โ— Three o three +โ”‚ โ—‹ Eight o eight +โ”” +", + "", + "", + "", + "โ—‡ foo +โ”‚ Three o three", + " +", + "", +] +`; diff --git a/packages/jsx/test/components/__snapshots__/text.test.tsx.snap b/packages/jsx/test/components/__snapshots__/text.test.tsx.snap new file mode 100644 index 00000000..72a9ace1 --- /dev/null +++ b/packages/jsx/test/components/__snapshots__/text.test.tsx.snap @@ -0,0 +1,77 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Text > can set default value 1`] = ` +[ + "", + "โ”‚ +โ—† foo +โ”‚ _ +โ”” +", + "", + "", + "", + "โ—‡ foo +โ”‚ bar", + " +", + "", +] +`; + +exports[`Text > can set initial value 1`] = ` +[ + "", + "โ”‚ +โ—† foo +โ”‚ barโ–ˆ +โ”” +", + "", + "", + "", + "โ—‡ foo +โ”‚ bar", + " +", + "", +] +`; + +exports[`Text > can set placeholder 1`] = ` +[ + "", + "โ”‚ +โ—† foo +โ”‚ bar +โ”” +", + "", + "", + "", + "โ—‡ foo +โ”‚", + " +", + "", +] +`; + +exports[`Text > renders text input 1`] = ` +[ + "", + "โ”‚ +โ—† foo +โ”‚ _ +โ”” +", + "", + "", + "", + "โ—‡ foo +โ”‚", + " +", + "", +] +`; diff --git a/packages/jsx/test/components/confirm.test.tsx b/packages/jsx/test/components/confirm.test.tsx new file mode 100644 index 00000000..2485b1b4 --- /dev/null +++ b/packages/jsx/test/components/confirm.test.tsx @@ -0,0 +1,37 @@ +import { MockReadable, MockWritable } from '@clack/test-utils'; +import { beforeEach, describe, expect, test } from 'vitest'; +import { Confirm } from '../../src/index.js'; + +describe('Confirm', () => { + let input: MockReadable; + let output: MockWritable; + + beforeEach(() => { + input = new MockReadable(); + output = new MockWritable(); + }); + + test('can set message', async () => { + const task = ; + input.emit('keypress', '', { name: 'return' }); + const result = await task; + expect(result).to.equal(true); + expect(output.buffer).toMatchSnapshot(); + }); + + test('can set active text', async () => { + const task = ; + input.emit('keypress', '', { name: 'return' }); + const result = await task; + expect(result).to.equal(true); + expect(output.buffer).toMatchSnapshot(); + }); + + test('can set inactive text', async () => { + const task = ; + input.emit('keypress', '', { name: 'return' }); + const result = await task; + expect(result).to.equal(true); + expect(output.buffer).toMatchSnapshot(); + }); +}); diff --git a/packages/jsx/test/components/note.test.tsx b/packages/jsx/test/components/note.test.tsx new file mode 100644 index 00000000..9c5ba725 --- /dev/null +++ b/packages/jsx/test/components/note.test.tsx @@ -0,0 +1,53 @@ +import { MockReadable, MockWritable } from '@clack/test-utils'; +import { beforeEach, describe, expect, test } from 'vitest'; +import { Confirm, Note } from '../../src/index.js'; + +describe('Note', () => { + let input: MockReadable; + let output: MockWritable; + + beforeEach(() => { + input = new MockReadable(); + output = new MockWritable(); + }); + + test('can render string message', async () => { + const task = ; + await task; + + expect(output.buffer).toMatchSnapshot(); + }); + + test('can render children as message', async () => { + const task = a message; + await task; + + expect(output.buffer).toMatchSnapshot(); + }); + + test('can render complex results as message', async () => { + const task = ( + + + + ); + input.emit('keypress', '', { name: 'return' }); + await task; + + expect(output.buffer).toMatchSnapshot(); + }); + + test('can render multiple children as message', async () => { + const task = ( + + + + + ); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '', { name: 'return' }); + await task; + + expect(output.buffer).toMatchSnapshot(); + }); +}); diff --git a/packages/jsx/test/components/password.test.tsx b/packages/jsx/test/components/password.test.tsx new file mode 100644 index 00000000..745b3c47 --- /dev/null +++ b/packages/jsx/test/components/password.test.tsx @@ -0,0 +1,50 @@ +import { MockReadable, MockWritable } from '@clack/test-utils'; +import { beforeEach, describe, expect, test } from 'vitest'; +import { Password } from '../../src/index.js'; + +describe('Password', () => { + let input: MockReadable; + let output: MockWritable; + + beforeEach(() => { + input = new MockReadable(); + output = new MockWritable(); + }); + + test('renders password input', async () => { + const task = ; + + input.emit('keypress', '', { name: 'return' }); + + const result = await task; + + expect(result).to.equal(undefined); + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders user input', async () => { + const task = ; + + input.emit('keypress', 'a', { name: 'a' }); + input.emit('keypress', 'b', { name: 'b' }); + input.emit('keypress', '', { name: 'return' }); + + const result = await task; + + expect(result).to.equal('ab'); + expect(output.buffer).toMatchSnapshot(); + }); + + test('can set custom mask', async () => { + const task = ; + + input.emit('keypress', 'a', { name: 'a' }); + input.emit('keypress', 'b', { name: 'b' }); + input.emit('keypress', '', { name: 'return' }); + + const result = await task; + + expect(result).to.equal('ab'); + expect(output.buffer).toMatchSnapshot(); + }); +}); diff --git a/packages/jsx/test/components/select.test.tsx b/packages/jsx/test/components/select.test.tsx new file mode 100644 index 00000000..d8457cab --- /dev/null +++ b/packages/jsx/test/components/select.test.tsx @@ -0,0 +1,74 @@ +import { MockReadable, MockWritable } from '@clack/test-utils'; +import { beforeEach, describe, expect, test } from 'vitest'; +import { Option, Select } from '../../src/index.js'; + +describe('Select', () => { + let input: MockReadable; + let output: MockWritable; + + beforeEach(() => { + input = new MockReadable(); + output = new MockWritable(); + }); + + test('renders options', async () => { + const task = ( + + ); + + // wait a tick... sad times + await new Promise((res) => setTimeout(res, 0)); + + input.emit('keypress', '', { name: 'return' }); + + const result = await task; + + expect(result).to.equal('opt0'); + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders options with labels', async () => { + const task = ( + + ); + + // wait a tick... sad times + await new Promise((res) => setTimeout(res, 0)); + + input.emit('keypress', '', { name: 'return' }); + + const result = await task; + + expect(result).to.equal(303); + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders options with hints', async () => { + const task = ( + + ); + + // wait a tick... sad times + await new Promise((res) => setTimeout(res, 0)); + + input.emit('keypress', '', { name: 'return' }); + + const result = await task; + + expect(result).to.equal(303); + expect(output.buffer).toMatchSnapshot(); + }); +}); diff --git a/packages/jsx/test/components/text.test.tsx b/packages/jsx/test/components/text.test.tsx new file mode 100644 index 00000000..dd5e29d9 --- /dev/null +++ b/packages/jsx/test/components/text.test.tsx @@ -0,0 +1,57 @@ +import { MockReadable, MockWritable } from '@clack/test-utils'; +import { beforeEach, describe, expect, test } from 'vitest'; +import { Text } from '../../src/index.js'; + +describe('Text', () => { + let input: MockReadable; + let output: MockWritable; + + beforeEach(() => { + input = new MockReadable(); + output = new MockWritable(); + }); + + test('renders text input', async () => { + const task = ; + + input.emit('keypress', '', { name: 'return' }); + + const result = await task; + + expect(result).to.equal(''); + expect(output.buffer).toMatchSnapshot(); + }); + + test('can set placeholder', async () => { + const task = ; + + input.emit('keypress', '', { name: 'return' }); + + const result = await task; + + expect(result).to.equal(''); + expect(output.buffer).toMatchSnapshot(); + }); + + test('can set default value', async () => { + const task = ; + + input.emit('keypress', '', { name: 'return' }); + + const result = await task; + + expect(result).to.equal('bar'); + expect(output.buffer).toMatchSnapshot(); + }); + + test('can set initial value', async () => { + const task = ; + + input.emit('keypress', '', { name: 'return' }); + + const result = await task; + + expect(result).to.equal('bar'); + expect(output.buffer).toMatchSnapshot(); + }); +}); diff --git a/packages/jsx/test/jsx.test.tsx b/packages/jsx/test/jsx.test.tsx index d7cab1e8..87625e55 100644 --- a/packages/jsx/test/jsx.test.tsx +++ b/packages/jsx/test/jsx.test.tsx @@ -1,6 +1,6 @@ import { MockReadable, MockWritable } from '@clack/test-utils'; import { beforeEach, describe, expect, test } from 'vitest'; -import { Confirm, Note, Option, Password, Select, Text, jsx } from '../src/index.js'; +import { Confirm, jsx } from '../src/index.js'; describe('jsx', () => { let input: MockReadable; @@ -34,175 +34,4 @@ describe('jsx', () => { const result = await task; expect(result).to.equal(null); }); - - describe('Confirm', () => { - test('can set message', async () => { - const task = ; - input.emit('keypress', '', { name: 'return' }); - const result = await task; - expect(result).to.equal(true); - expect(output.buffer).toMatchSnapshot(); - }); - - test('can set active text', async () => { - const task = ; - input.emit('keypress', '', { name: 'return' }); - const result = await task; - expect(result).to.equal(true); - expect(output.buffer).toMatchSnapshot(); - }); - - test('can set inactive text', async () => { - const task = ; - input.emit('keypress', '', { name: 'return' }); - const result = await task; - expect(result).to.equal(true); - expect(output.buffer).toMatchSnapshot(); - }); - }); - - describe('Note', () => { - test('can render string message', async () => { - const task = ; - await task; - - expect(output.buffer).toMatchSnapshot(); - }); - - test('can render children as message', async () => { - const task = a message; - await task; - - expect(output.buffer).toMatchSnapshot(); - }); - - test('can render complex results as message', async () => { - const task = ( - - - - ); - input.emit('keypress', '', { name: 'return' }); - await task; - - expect(output.buffer).toMatchSnapshot(); - }); - - test('can render multiple children as message', async () => { - const task = ( - - - - - ); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', '', { name: 'return' }); - await task; - - expect(output.buffer).toMatchSnapshot(); - }); - }); - - describe('Text', () => { - test('renders text input', async () => { - const task = ; - - input.emit('keypress', '', { name: 'return' }); - - const result = await task; - - expect(result).to.equal(''); - expect(output.buffer).toMatchSnapshot(); - }); - - test('can set placeholder', async () => { - const task = ; - - input.emit('keypress', '', { name: 'return' }); - - const result = await task; - - expect(result).to.equal(''); - expect(output.buffer).toMatchSnapshot(); - }); - - test('can set default value', async () => { - const task = ; - - input.emit('keypress', '', { name: 'return' }); - - const result = await task; - - expect(result).to.equal('bar'); - expect(output.buffer).toMatchSnapshot(); - }); - - test('can set initial value', async () => { - const task = ; - - input.emit('keypress', '', { name: 'return' }); - - const result = await task; - - expect(result).to.equal('bar'); - expect(output.buffer).toMatchSnapshot(); - }); - }); - - describe('Password', () => { - test('renders password input', async () => { - const task = ; - - input.emit('keypress', '', { name: 'return' }); - - const result = await task; - - expect(result).to.equal(undefined); - expect(output.buffer).toMatchSnapshot(); - }); - - test('renders user input', async () => { - const task = ; - - input.emit('keypress', 'a', { name: 'a' }); - input.emit('keypress', 'b', { name: 'b' }); - input.emit('keypress', '', { name: 'return' }); - - const result = await task; - - expect(result).to.equal('ab'); - expect(output.buffer).toMatchSnapshot(); - }); - - test('can set custom mask', async () => { - const task = ; - - input.emit('keypress', 'a', { name: 'a' }); - input.emit('keypress', 'b', { name: 'b' }); - input.emit('keypress', '', { name: 'return' }); - - const result = await task; - - expect(result).to.equal('ab'); - expect(output.buffer).toMatchSnapshot(); - }); - }); - - describe('Select', () => { - test('renders options', async () => { - const task = ( - - ); - - input.emit('keypress', '', { name: 'return' }); - - const result = await task; - - expect(result).to.equal(303); - expect(output.buffer).toMatchSnapshot(); - }); - }); }); From bc903a2da10ea0774205b796408a0b5385f282bd Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Wed, 9 Jul 2025 16:37:25 +0100 Subject: [PATCH 5/8] wip: make jsx elements functions --- packages/jsx/src/components/confirm.ts | 4 +- packages/jsx/src/components/field.ts | 41 +++++++++++ packages/jsx/src/components/form.ts | 33 +++++++++ packages/jsx/src/components/note.ts | 32 ++++---- packages/jsx/src/components/option.ts | 34 +++++---- packages/jsx/src/components/password.ts | 4 +- packages/jsx/src/components/select.ts | 26 ++++--- packages/jsx/src/components/text.ts | 4 +- packages/jsx/src/index.ts | 10 ++- packages/jsx/src/types.ts | 2 +- packages/jsx/src/utils.ts | 2 +- .../__snapshots__/field.test.tsx.snap | 73 +++++++++++++++++++ .../__snapshots__/note.test.tsx.snap | 12 +-- packages/jsx/test/components/confirm.test.tsx | 6 +- packages/jsx/test/components/field.test.tsx | 56 ++++++++++++++ packages/jsx/test/components/note.test.tsx | 11 +-- .../jsx/test/components/password.test.tsx | 6 +- packages/jsx/test/components/select.test.tsx | 20 ++--- packages/jsx/test/components/text.test.tsx | 8 +- packages/jsx/test/jsx.test.tsx | 6 +- 20 files changed, 300 insertions(+), 90 deletions(-) create mode 100644 packages/jsx/src/components/field.ts create mode 100644 packages/jsx/src/components/form.ts create mode 100644 packages/jsx/test/components/__snapshots__/field.test.tsx.snap create mode 100644 packages/jsx/test/components/field.test.tsx diff --git a/packages/jsx/src/components/confirm.ts b/packages/jsx/src/components/confirm.ts index 2293d80e..13c6da36 100644 --- a/packages/jsx/src/components/confirm.ts +++ b/packages/jsx/src/components/confirm.ts @@ -3,6 +3,6 @@ import { confirm } from '@clack/prompts'; export type ConfirmProps = ConfirmOptions; -export function Confirm(props: ConfirmProps): ReturnType { - return confirm(props); +export function Confirm(props: ConfirmProps): () => ReturnType { + return () => confirm(props); } diff --git a/packages/jsx/src/components/field.ts b/packages/jsx/src/components/field.ts new file mode 100644 index 00000000..834642c8 --- /dev/null +++ b/packages/jsx/src/components/field.ts @@ -0,0 +1,41 @@ +import { isCancel } from '@clack/prompts'; +import type { JSX } from '../types.js'; +import { resolveChildren } from '../utils.js'; + +export interface FieldResult { + name: PropertyKey; + value: unknown; +} + +export interface FieldProps { + name: PropertyKey; + children?: JSX.Element | JSX.Element[] | string; +} + +export function Field(props: FieldProps): () => Promise { + return async () => { + let value: unknown = undefined; + + if (props.children) { + const resolvedChildren = await resolveChildren(props.children); + const valueArr: unknown[] = []; + + for (const child of resolvedChildren) { + if (!isCancel(child)) { + valueArr.push(child); + } + } + + if (valueArr.length === 1) { + value = valueArr[0]; + } else { + value = valueArr; + } + } + + return { + name: props.name, + value, + }; + }; +} diff --git a/packages/jsx/src/components/form.ts b/packages/jsx/src/components/form.ts new file mode 100644 index 00000000..49253c62 --- /dev/null +++ b/packages/jsx/src/components/form.ts @@ -0,0 +1,33 @@ +import { isCancel } from '@clack/prompts'; +import type { JSX } from '../types.js'; +import { resolveChildren } from '../utils.js'; + +export interface FormProps { + children?: JSX.Element | JSX.Element[] | string; +} + +function isChildLike(child: unknown): child is { name: PropertyKey; value: unknown } { + return typeof child === 'object' && child !== null && 'name' in child && 'value' in child; +} + +export function Form(props: FormProps): () => Promise> { + return async () => { + const results: Record = {}; + + if (props.children) { + const resolvedChildren = await resolveChildren(props.children); + + for (const child of resolvedChildren) { + if (isCancel(child)) { + continue; + } + + if (isChildLike(child)) { + results[child.name] = child.value; + } + } + } + + return results; + }; +} diff --git a/packages/jsx/src/components/note.ts b/packages/jsx/src/components/note.ts index 94ac17de..2142dde9 100644 --- a/packages/jsx/src/components/note.ts +++ b/packages/jsx/src/components/note.ts @@ -9,23 +9,25 @@ export interface NoteProps extends NoteOptions { title?: string; } -export async function Note(props: NoteProps): Promise { - let message = ''; +export function Note(props: NoteProps): () => Promise { + return async () => { + let message = ''; - if (props.children) { - const messages: string[] = []; - const children = await resolveChildren(props.children); - for (const child of children) { - // TODO (43081j): handle cancelling of children - if (isCancel(child)) { - continue; + if (props.children) { + const messages: string[] = []; + const children = await resolveChildren(props.children); + for (const child of children) { + // TODO (43081j): handle cancelling of children + if (isCancel(child)) { + continue; + } + messages.push(String(child)); } - messages.push(String(child)); + message = messages.join('\n'); + } else if (props.message) { + message = props.message; } - message = messages.join('\n'); - } else if (props.message) { - message = props.message; - } - note(message, props.title, props); + note(message, props.title, props); + }; } diff --git a/packages/jsx/src/components/option.ts b/packages/jsx/src/components/option.ts index a49b839b..9eec3f2c 100644 --- a/packages/jsx/src/components/option.ts +++ b/packages/jsx/src/components/option.ts @@ -8,25 +8,27 @@ export interface OptionProps { children?: JSX.Element | JSX.Element[] | string; } -export async function Option(props: OptionProps): Promise> { - const { children, ...opts } = props; +export function Option(props: OptionProps): () => Promise> { + return async () => { + const { children, ...opts } = props; - if (children) { - const resolvedChildren = await resolveChildren(children); - const childStrings: string[] = []; + if (children) { + const resolvedChildren = await resolveChildren(children); + const childStrings: string[] = []; - for (const child of resolvedChildren) { - if (isCancel(child)) { - continue; + for (const child of resolvedChildren) { + if (isCancel(child)) { + continue; + } + childStrings.push(String(child)); } - childStrings.push(String(child)); - } - return { - ...opts, - label: childStrings.join('\n'), - } as PromptOption; - } + return { + ...opts, + label: childStrings.join('\n'), + } as PromptOption; + } - return opts as PromptOption; + return opts as PromptOption; + }; } diff --git a/packages/jsx/src/components/password.ts b/packages/jsx/src/components/password.ts index a41b3f13..4de77aa9 100644 --- a/packages/jsx/src/components/password.ts +++ b/packages/jsx/src/components/password.ts @@ -3,6 +3,6 @@ import { password } from '@clack/prompts'; export type PasswordProps = PasswordOptions; -export function Password(props: PasswordProps): ReturnType { - return password(props); +export function Password(props: PasswordProps): () => ReturnType { + return () => password(props); } diff --git a/packages/jsx/src/components/select.ts b/packages/jsx/src/components/select.ts index 08c2c102..8d7e6a71 100644 --- a/packages/jsx/src/components/select.ts +++ b/packages/jsx/src/components/select.ts @@ -11,19 +11,21 @@ const isOptionLike = (obj: unknown): obj is Option => { return obj !== null && typeof obj === 'object' && Object.hasOwnProperty.call(obj, 'value'); }; -export async function Select(props: SelectProps): ReturnType { - const { children, ...opts } = props; - const options: Option[] = []; - const resolvedChildren = await resolveChildren(props.children); +export function Select(props: SelectProps): () => ReturnType { + return async () => { + const { children, ...opts } = props; + const options: Option[] = []; + const resolvedChildren = await resolveChildren(props.children); - for (const child of resolvedChildren) { - if (isOptionLike(child)) { - options.push(child); + for (const child of resolvedChildren) { + if (isOptionLike(child)) { + options.push(child); + } } - } - return select({ - ...opts, - options, - }); + return select({ + ...opts, + options, + }); + }; } diff --git a/packages/jsx/src/components/text.ts b/packages/jsx/src/components/text.ts index 1678a9e5..495f7b54 100644 --- a/packages/jsx/src/components/text.ts +++ b/packages/jsx/src/components/text.ts @@ -3,6 +3,6 @@ import { text } from '@clack/prompts'; export type TextProps = TextOptions; -export function Text(props: TextProps): ReturnType { - return text(props); +export function Text(props: TextProps): () => ReturnType { + return () => text(props); } diff --git a/packages/jsx/src/index.ts b/packages/jsx/src/index.ts index 5777b67b..a40dc287 100644 --- a/packages/jsx/src/index.ts +++ b/packages/jsx/src/index.ts @@ -1,4 +1,6 @@ import { Confirm, type ConfirmProps } from './components/confirm.js'; +import { Field, type FieldProps } from './components/field.js'; +import { Form, type FormProps } from './components/form.js'; import { Note, type NoteProps } from './components/note.js'; import { Option, type OptionProps } from './components/option.js'; import { Password, type PasswordProps } from './components/password.js'; @@ -20,10 +22,14 @@ export { type OptionProps, Select, type SelectProps, + Field, + type FieldProps, + Form, + type FormProps, }; export function Fragment(props: { children: JSX.Element | JSX.Element[] }): JSX.Element { - return Promise.resolve(props.children); + return () => Promise.resolve(props.children); } export type Component = @@ -44,7 +50,7 @@ function jsx(tagOrFn: string | Component, props: unknown, _key?: string): JSX.El if (typeof tagOrFn === 'function') { return (tagOrFn as (props: unknown) => JSX.Element)(props); } - return Promise.resolve(null); + return () => Promise.resolve(null); } export { jsx }; diff --git a/packages/jsx/src/types.ts b/packages/jsx/src/types.ts index 70519daa..0da9cd75 100644 --- a/packages/jsx/src/types.ts +++ b/packages/jsx/src/types.ts @@ -1,7 +1,7 @@ namespace JSX { export type IntrinsicElements = never; - export type Element = Promise; + export type Element = () => Promise; } export type { JSX }; diff --git a/packages/jsx/src/utils.ts b/packages/jsx/src/utils.ts index b9619986..6b96af21 100644 --- a/packages/jsx/src/utils.ts +++ b/packages/jsx/src/utils.ts @@ -7,7 +7,7 @@ export async function resolveChildren( const results: unknown[] = []; for (const child of arr) { - const result = await child; + const result = typeof child === 'string' ? child : await child(); results.push(result); } diff --git a/packages/jsx/test/components/__snapshots__/field.test.tsx.snap b/packages/jsx/test/components/__snapshots__/field.test.tsx.snap new file mode 100644 index 00000000..89dc6e88 --- /dev/null +++ b/packages/jsx/test/components/__snapshots__/field.test.tsx.snap @@ -0,0 +1,73 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Field > renders and resolves children 1`] = ` +[ + "", + "โ”‚ +โ—† enter some text +โ”‚ _ +โ”” +", + "", + "", + "", + "โ”‚ aโ–ˆ", + "", + "", + "", + "", + "โ”‚ abโ–ˆ", + "", + "", + "", + "", + "โ—‡ enter some text +โ”‚ ab", + " +", + "", +] +`; + +exports[`Field > resolves multiple children into array 1`] = ` +[ + "", + "โ”‚ +โ—† enter some text +โ”‚ _ +โ”” +", + "", + "", + "", + "โ”‚ aโ–ˆ", + "", + "", + "", + "", + "โ—‡ enter some text +โ”‚ a", + " +", + "", + "", + "โ”‚ +โ—† enter some more text +โ”‚ _ +โ”” +", + "", + "", + "", + "โ”‚ bโ–ˆ", + "", + "", + "", + "", + "โ—‡ enter some more text +โ”‚ b", + " +", + "", +] +`; diff --git a/packages/jsx/test/components/__snapshots__/note.test.tsx.snap b/packages/jsx/test/components/__snapshots__/note.test.tsx.snap index e67dcbb8..da2ac26f 100644 --- a/packages/jsx/test/components/__snapshots__/note.test.tsx.snap +++ b/packages/jsx/test/components/__snapshots__/note.test.tsx.snap @@ -45,12 +45,6 @@ exports[`Note > can render multiple children as message 1`] = ` โ—† say yes โ”‚ โ— Yes / โ—‹ No โ”” -", - "", - "โ”‚ -โ—† say yes again -โ”‚ โ— Yes / โ—‹ No -โ”” ", "", "", @@ -60,6 +54,12 @@ exports[`Note > can render multiple children as message 1`] = ` " ", "", + "", + "โ”‚ +โ—† say yes again +โ”‚ โ— Yes / โ—‹ No +โ”” +", "", "", "", diff --git a/packages/jsx/test/components/confirm.test.tsx b/packages/jsx/test/components/confirm.test.tsx index 2485b1b4..caca6c79 100644 --- a/packages/jsx/test/components/confirm.test.tsx +++ b/packages/jsx/test/components/confirm.test.tsx @@ -12,7 +12,7 @@ describe('Confirm', () => { }); test('can set message', async () => { - const task = ; + const task = ()(); input.emit('keypress', '', { name: 'return' }); const result = await task; expect(result).to.equal(true); @@ -20,7 +20,7 @@ describe('Confirm', () => { }); test('can set active text', async () => { - const task = ; + const task = ()(); input.emit('keypress', '', { name: 'return' }); const result = await task; expect(result).to.equal(true); @@ -28,7 +28,7 @@ describe('Confirm', () => { }); test('can set inactive text', async () => { - const task = ; + const task = ()(); input.emit('keypress', '', { name: 'return' }); const result = await task; expect(result).to.equal(true); diff --git a/packages/jsx/test/components/field.test.tsx b/packages/jsx/test/components/field.test.tsx new file mode 100644 index 00000000..9ecb7c09 --- /dev/null +++ b/packages/jsx/test/components/field.test.tsx @@ -0,0 +1,56 @@ +import { MockReadable, MockWritable, nextTick } from '@clack/test-utils'; +import { beforeEach, describe, expect, test } from 'vitest'; +import { Field, Text } from '../../src/index.js'; + +describe('Field', () => { + let input: MockReadable; + let output: MockWritable; + + beforeEach(() => { + input = new MockReadable(); + output = new MockWritable(); + }); + + test('renders and resolves children', async () => { + const task = ( + + + + )(); + + input.emit('keypress', 'a', { name: 'a' }); + input.emit('keypress', 'b', { name: 'b' }); + input.emit('keypress', '', { name: 'return' }); + + const result = await task; + + expect(result).to.deep.equal({ + name: 'foo', + value: 'ab', + }); + expect(output.buffer).toMatchSnapshot(); + }); + + test('resolves multiple children into array', async () => { + const task = ( + + + + + )(); + + input.emit('keypress', 'a', { name: 'a' }); + input.emit('keypress', '', { name: 'return' }); + await nextTick(); + input.emit('keypress', 'b', { name: 'b' }); + input.emit('keypress', '', { name: 'return' }); + + const result = await task; + + expect(result).to.deep.equal({ + name: 'foo', + value: ['a', 'b'], + }); + expect(output.buffer).toMatchSnapshot(); + }); +}); diff --git a/packages/jsx/test/components/note.test.tsx b/packages/jsx/test/components/note.test.tsx index 9c5ba725..11810514 100644 --- a/packages/jsx/test/components/note.test.tsx +++ b/packages/jsx/test/components/note.test.tsx @@ -1,4 +1,4 @@ -import { MockReadable, MockWritable } from '@clack/test-utils'; +import { MockReadable, MockWritable, nextTick } from '@clack/test-utils'; import { beforeEach, describe, expect, test } from 'vitest'; import { Confirm, Note } from '../../src/index.js'; @@ -13,14 +13,14 @@ describe('Note', () => { test('can render string message', async () => { const task = ; - await task; + await task(); expect(output.buffer).toMatchSnapshot(); }); test('can render children as message', async () => { const task = a message; - await task; + await task(); expect(output.buffer).toMatchSnapshot(); }); @@ -30,7 +30,7 @@ describe('Note', () => { - ); + )(); input.emit('keypress', '', { name: 'return' }); await task; @@ -43,8 +43,9 @@ describe('Note', () => { - ); + )(); input.emit('keypress', '', { name: 'return' }); + await nextTick(); input.emit('keypress', '', { name: 'return' }); await task; diff --git a/packages/jsx/test/components/password.test.tsx b/packages/jsx/test/components/password.test.tsx index 745b3c47..a5522524 100644 --- a/packages/jsx/test/components/password.test.tsx +++ b/packages/jsx/test/components/password.test.tsx @@ -12,7 +12,7 @@ describe('Password', () => { }); test('renders password input', async () => { - const task = ; + const task = ()(); input.emit('keypress', '', { name: 'return' }); @@ -23,7 +23,7 @@ describe('Password', () => { }); test('renders user input', async () => { - const task = ; + const task = ()(); input.emit('keypress', 'a', { name: 'a' }); input.emit('keypress', 'b', { name: 'b' }); @@ -36,7 +36,7 @@ describe('Password', () => { }); test('can set custom mask', async () => { - const task = ; + const task = ()(); input.emit('keypress', 'a', { name: 'a' }); input.emit('keypress', 'b', { name: 'b' }); diff --git a/packages/jsx/test/components/select.test.tsx b/packages/jsx/test/components/select.test.tsx index d8457cab..0a98c100 100644 --- a/packages/jsx/test/components/select.test.tsx +++ b/packages/jsx/test/components/select.test.tsx @@ -1,4 +1,4 @@ -import { MockReadable, MockWritable } from '@clack/test-utils'; +import { MockReadable, MockWritable, nextTick } from '@clack/test-utils'; import { beforeEach, describe, expect, test } from 'vitest'; import { Option, Select } from '../../src/index.js'; @@ -17,11 +17,9 @@ describe('Select', () => { - ); - - // wait a tick... sad times - await new Promise((res) => setTimeout(res, 0)); + )(); + await nextTick(); input.emit('keypress', '', { name: 'return' }); const result = await task; @@ -59,11 +55,9 @@ describe('Select', () => { Eight o eight - ); - - // wait a tick... sad times - await new Promise((res) => setTimeout(res, 0)); + )(); + await nextTick(); input.emit('keypress', '', { name: 'return' }); const result = await task; diff --git a/packages/jsx/test/components/text.test.tsx b/packages/jsx/test/components/text.test.tsx index dd5e29d9..169b050b 100644 --- a/packages/jsx/test/components/text.test.tsx +++ b/packages/jsx/test/components/text.test.tsx @@ -12,7 +12,7 @@ describe('Text', () => { }); test('renders text input', async () => { - const task = ; + const task = ()(); input.emit('keypress', '', { name: 'return' }); @@ -23,7 +23,7 @@ describe('Text', () => { }); test('can set placeholder', async () => { - const task = ; + const task = ()(); input.emit('keypress', '', { name: 'return' }); @@ -34,7 +34,7 @@ describe('Text', () => { }); test('can set default value', async () => { - const task = ; + const task = ()(); input.emit('keypress', '', { name: 'return' }); @@ -45,7 +45,7 @@ describe('Text', () => { }); test('can set initial value', async () => { - const task = ; + const task = ()(); input.emit('keypress', '', { name: 'return' }); diff --git a/packages/jsx/test/jsx.test.tsx b/packages/jsx/test/jsx.test.tsx index 87625e55..0c93c9ef 100644 --- a/packages/jsx/test/jsx.test.tsx +++ b/packages/jsx/test/jsx.test.tsx @@ -16,14 +16,14 @@ describe('jsx', () => { message: 'foo?', input, output, - }); + })(); input.emit('keypress', '', { name: 'return' }); const result = await task; expect(result).to.equal(true); }); test('can render JSX', async () => { - const task = ; + const task = ()(); input.emit('keypress', '', { name: 'return' }); const result = await task; expect(result).to.equal(true); @@ -31,7 +31,7 @@ describe('jsx', () => { test('unknown elements are null', async () => { const task = jsx('unknown-nonsense' as never, {} as never); - const result = await task; + const result = await task(); expect(result).to.equal(null); }); }); From 4d639194059b87726244be008d0bbb2aa9d3d97d Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Wed, 9 Jul 2025 16:40:06 +0100 Subject: [PATCH 6/8] wip: add form tests --- .../__snapshots__/form.test.tsx.snap | 73 +++++++++++++++++++ packages/jsx/test/components/form.test.tsx | 60 +++++++++++++++ packages/test-utils/src/index.ts | 6 ++ 3 files changed, 139 insertions(+) create mode 100644 packages/jsx/test/components/__snapshots__/form.test.tsx.snap create mode 100644 packages/jsx/test/components/form.test.tsx diff --git a/packages/jsx/test/components/__snapshots__/form.test.tsx.snap b/packages/jsx/test/components/__snapshots__/form.test.tsx.snap new file mode 100644 index 00000000..cbf1ee1c --- /dev/null +++ b/packages/jsx/test/components/__snapshots__/form.test.tsx.snap @@ -0,0 +1,73 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Form > renders and resolves multiple fields 1`] = ` +[ + "", + "โ”‚ +โ—† enter some text +โ”‚ _ +โ”” +", + "", + "", + "", + "โ”‚ aโ–ˆ", + "", + "", + "", + "", + "โ—‡ enter some text +โ”‚ a", + " +", + "", + "", + "โ”‚ +โ—† enter some other text +โ”‚ _ +โ”” +", + "", + "", + "", + "โ”‚ bโ–ˆ", + "", + "", + "", + "", + "โ—‡ enter some other text +โ”‚ b", + " +", + "", +] +`; + +exports[`Form > renders and resolves object 1`] = ` +[ + "", + "โ”‚ +โ—† enter some text +โ”‚ _ +โ”” +", + "", + "", + "", + "โ”‚ aโ–ˆ", + "", + "", + "", + "", + "โ”‚ abโ–ˆ", + "", + "", + "", + "", + "โ—‡ enter some text +โ”‚ ab", + " +", + "", +] +`; diff --git a/packages/jsx/test/components/form.test.tsx b/packages/jsx/test/components/form.test.tsx new file mode 100644 index 00000000..40393a56 --- /dev/null +++ b/packages/jsx/test/components/form.test.tsx @@ -0,0 +1,60 @@ +import { MockReadable, MockWritable, nextTick } from '@clack/test-utils'; +import { beforeEach, describe, expect, test } from 'vitest'; +import { Field, Form, Text } from '../../src/index.js'; + +describe('Form', () => { + let input: MockReadable; + let output: MockWritable; + + beforeEach(() => { + input = new MockReadable(); + output = new MockWritable(); + }); + + test('renders and resolves object', async () => { + const task = ( +
+ + + +
+ )(); + + input.emit('keypress', 'a', { name: 'a' }); + input.emit('keypress', 'b', { name: 'b' }); + input.emit('keypress', '', { name: 'return' }); + + const result = await task; + + expect(result).to.deep.equal({ + foo: 'ab', + }); + expect(output.buffer).toMatchSnapshot(); + }); + test('renders and resolves multiple fields', async () => { + const task = ( +
+ + + + + + +
+ )(); + + input.emit('keypress', 'a', { name: 'a' }); + input.emit('keypress', '', { name: 'return' }); + await nextTick(); + input.emit('keypress', 'b', { name: 'b' }); + input.emit('keypress', '', { name: 'return' }); + + const result = await task; + + expect(result).to.deep.equal({ + foo: 'a', + bar: 'b', + }); + expect(output.buffer).toMatchSnapshot(); + }); +}); diff --git a/packages/test-utils/src/index.ts b/packages/test-utils/src/index.ts index fda11f44..d4cfd577 100644 --- a/packages/test-utils/src/index.ts +++ b/packages/test-utils/src/index.ts @@ -1,2 +1,8 @@ export { MockReadable } from './mock-readable.js'; export { MockWritable } from './mock-writable.js'; + +export function nextTick(): Promise { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} From 36e756b7c3f679b9866b2dbb8e05a3d9f0c8d0ea Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Sun, 13 Jul 2025 13:51:41 +0100 Subject: [PATCH 7/8] wip: rework it all to use render() functions This way we can keep an ast-like thingymajig around until we want to actually render. --- packages/jsx/src/components/confirm.ts | 12 +++++- packages/jsx/src/components/field.ts | 40 ++++++++++--------- packages/jsx/src/components/form.ts | 28 +++++++------ packages/jsx/src/components/note.ts | 38 ++++++++++-------- packages/jsx/src/components/option.ts | 36 +++++++++-------- packages/jsx/src/components/password.ts | 12 +++++- packages/jsx/src/components/select.ts | 30 ++++++++------ packages/jsx/src/components/text.ts | 12 +++++- packages/jsx/src/index.ts | 20 ++++++++-- packages/jsx/src/types.ts | 10 ++++- packages/jsx/src/utils.ts | 7 ++-- packages/jsx/test/components/confirm.test.tsx | 9 +++-- packages/jsx/test/components/field.test.tsx | 16 ++++---- packages/jsx/test/components/form.test.tsx | 16 ++++---- packages/jsx/test/components/note.test.tsx | 30 ++++++++------ .../jsx/test/components/password.test.tsx | 9 +++-- packages/jsx/test/components/select.test.tsx | 21 +++++----- packages/jsx/test/components/text.test.tsx | 12 ++++-- packages/jsx/test/jsx.test.tsx | 14 +++---- 19 files changed, 227 insertions(+), 145 deletions(-) diff --git a/packages/jsx/src/components/confirm.ts b/packages/jsx/src/components/confirm.ts index 13c6da36..0c6bd135 100644 --- a/packages/jsx/src/components/confirm.ts +++ b/packages/jsx/src/components/confirm.ts @@ -1,8 +1,16 @@ import type { ConfirmOptions } from '@clack/prompts'; import { confirm } from '@clack/prompts'; +import type { JSX } from '../types.js'; export type ConfirmProps = ConfirmOptions; -export function Confirm(props: ConfirmProps): () => ReturnType { - return () => confirm(props); +export function Confirm(props: ConfirmProps): JSX.Element { + return { + render: (options) => + confirm({ + input: options?.input, + output: options?.output, + ...props, + }), + }; } diff --git a/packages/jsx/src/components/field.ts b/packages/jsx/src/components/field.ts index 834642c8..e68b1460 100644 --- a/packages/jsx/src/components/field.ts +++ b/packages/jsx/src/components/field.ts @@ -12,30 +12,32 @@ export interface FieldProps { children?: JSX.Element | JSX.Element[] | string; } -export function Field(props: FieldProps): () => Promise { - return async () => { - let value: unknown = undefined; +export function Field(props: FieldProps): JSX.Element { + return { + render: async (options) => { + let value: unknown = undefined; - if (props.children) { - const resolvedChildren = await resolveChildren(props.children); - const valueArr: unknown[] = []; + if (props.children) { + const resolvedChildren = await resolveChildren(props.children, options); + const valueArr: unknown[] = []; - for (const child of resolvedChildren) { - if (!isCancel(child)) { - valueArr.push(child); + for (const child of resolvedChildren) { + if (!isCancel(child)) { + valueArr.push(child); + } } - } - if (valueArr.length === 1) { - value = valueArr[0]; - } else { - value = valueArr; + if (valueArr.length === 1) { + value = valueArr[0]; + } else { + value = valueArr; + } } - } - return { - name: props.name, - value, - }; + return { + name: props.name, + value, + }; + }, }; } diff --git a/packages/jsx/src/components/form.ts b/packages/jsx/src/components/form.ts index 49253c62..49db85bf 100644 --- a/packages/jsx/src/components/form.ts +++ b/packages/jsx/src/components/form.ts @@ -10,24 +10,26 @@ function isChildLike(child: unknown): child is { name: PropertyKey; value: unkno return typeof child === 'object' && child !== null && 'name' in child && 'value' in child; } -export function Form(props: FormProps): () => Promise> { - return async () => { - const results: Record = {}; +export function Form(props: FormProps): JSX.Element { + return { + render: async (options) => { + const results: Record = {}; - if (props.children) { - const resolvedChildren = await resolveChildren(props.children); + if (props.children) { + const resolvedChildren = await resolveChildren(props.children, options); - for (const child of resolvedChildren) { - if (isCancel(child)) { - continue; - } + for (const child of resolvedChildren) { + if (isCancel(child)) { + continue; + } - if (isChildLike(child)) { - results[child.name] = child.value; + if (isChildLike(child)) { + results[child.name] = child.value; + } } } - } - return results; + return results; + }, }; } diff --git a/packages/jsx/src/components/note.ts b/packages/jsx/src/components/note.ts index 2142dde9..a48258f5 100644 --- a/packages/jsx/src/components/note.ts +++ b/packages/jsx/src/components/note.ts @@ -9,25 +9,31 @@ export interface NoteProps extends NoteOptions { title?: string; } -export function Note(props: NoteProps): () => Promise { - return async () => { - let message = ''; +export function Note(props: NoteProps): JSX.Element { + return { + render: async (options) => { + let message = ''; - if (props.children) { - const messages: string[] = []; - const children = await resolveChildren(props.children); - for (const child of children) { - // TODO (43081j): handle cancelling of children - if (isCancel(child)) { - continue; + if (props.children) { + const messages: string[] = []; + const children = await resolveChildren(props.children, options); + for (const child of children) { + // TODO (43081j): handle cancelling of children + if (isCancel(child)) { + continue; + } + messages.push(String(child)); } - messages.push(String(child)); + message = messages.join('\n'); + } else if (props.message) { + message = props.message; } - message = messages.join('\n'); - } else if (props.message) { - message = props.message; - } - note(message, props.title, props); + note(message, props.title, { + input: options?.input, + output: options?.output, + ...props, + }); + }, }; } diff --git a/packages/jsx/src/components/option.ts b/packages/jsx/src/components/option.ts index 9eec3f2c..cbec2462 100644 --- a/packages/jsx/src/components/option.ts +++ b/packages/jsx/src/components/option.ts @@ -8,27 +8,29 @@ export interface OptionProps { children?: JSX.Element | JSX.Element[] | string; } -export function Option(props: OptionProps): () => Promise> { - return async () => { - const { children, ...opts } = props; +export function Option(props: OptionProps): JSX.Element { + return { + render: async (options) => { + const { children, ...opts } = props; - if (children) { - const resolvedChildren = await resolveChildren(children); - const childStrings: string[] = []; + if (children) { + const resolvedChildren = await resolveChildren(children, options); + const childStrings: string[] = []; - for (const child of resolvedChildren) { - if (isCancel(child)) { - continue; + for (const child of resolvedChildren) { + if (isCancel(child)) { + continue; + } + childStrings.push(String(child)); } - childStrings.push(String(child)); - } - return { - ...opts, - label: childStrings.join('\n'), - } as PromptOption; - } + return { + ...opts, + label: childStrings.join('\n'), + } as PromptOption; + } - return opts as PromptOption; + return opts as PromptOption; + }, }; } diff --git a/packages/jsx/src/components/password.ts b/packages/jsx/src/components/password.ts index 4de77aa9..60c532c8 100644 --- a/packages/jsx/src/components/password.ts +++ b/packages/jsx/src/components/password.ts @@ -1,8 +1,16 @@ import type { PasswordOptions } from '@clack/prompts'; import { password } from '@clack/prompts'; +import type { JSX } from '../types.js'; export type PasswordProps = PasswordOptions; -export function Password(props: PasswordProps): () => ReturnType { - return () => password(props); +export function Password(props: PasswordProps): JSX.Element { + return { + render: (options) => + password({ + input: options?.input, + output: options?.output, + ...props, + }), + }; } diff --git a/packages/jsx/src/components/select.ts b/packages/jsx/src/components/select.ts index 8d7e6a71..612c7e75 100644 --- a/packages/jsx/src/components/select.ts +++ b/packages/jsx/src/components/select.ts @@ -11,21 +11,25 @@ const isOptionLike = (obj: unknown): obj is Option => { return obj !== null && typeof obj === 'object' && Object.hasOwnProperty.call(obj, 'value'); }; -export function Select(props: SelectProps): () => ReturnType { - return async () => { - const { children, ...opts } = props; - const options: Option[] = []; - const resolvedChildren = await resolveChildren(props.children); +export function Select(props: SelectProps): JSX.Element { + return { + render: async (renderOptions) => { + const { children, ...opts } = props; + const options: Option[] = []; + const resolvedChildren = await resolveChildren(props.children, renderOptions); - for (const child of resolvedChildren) { - if (isOptionLike(child)) { - options.push(child); + for (const child of resolvedChildren) { + if (isOptionLike(child)) { + options.push(child); + } } - } - return select({ - ...opts, - options, - }); + return select({ + input: renderOptions?.input, + output: renderOptions?.output, + ...opts, + options, + }); + }, }; } diff --git a/packages/jsx/src/components/text.ts b/packages/jsx/src/components/text.ts index 495f7b54..7f800bbb 100644 --- a/packages/jsx/src/components/text.ts +++ b/packages/jsx/src/components/text.ts @@ -1,8 +1,16 @@ import type { TextOptions } from '@clack/prompts'; import { text } from '@clack/prompts'; +import type { JSX } from '../types.js'; export type TextProps = TextOptions; -export function Text(props: TextProps): () => ReturnType { - return () => text(props); +export function Text(props: TextProps): JSX.Element { + return { + render: (options) => + text({ + input: options?.input, + output: options?.output, + ...props, + }), + }; } diff --git a/packages/jsx/src/index.ts b/packages/jsx/src/index.ts index a40dc287..4a8de096 100644 --- a/packages/jsx/src/index.ts +++ b/packages/jsx/src/index.ts @@ -6,7 +6,7 @@ import { Option, type OptionProps } from './components/option.js'; import { Password, type PasswordProps } from './components/password.js'; import { Select, type SelectProps } from './components/select.js'; import { Text, type TextProps } from './components/text.js'; -import type { JSX } from './types.js'; +import type { JSX, RenderFunction, RenderOptions } from './types.js'; export type { JSX }; export { @@ -29,7 +29,9 @@ export { }; export function Fragment(props: { children: JSX.Element | JSX.Element[] }): JSX.Element { - return () => Promise.resolve(props.children); + return { + render: () => Promise.resolve(props.children), + }; } export type Component = @@ -47,11 +49,21 @@ function jsx( ): JSX.Element; function jsx(fn: T, props: Parameters[0], _key?: string): JSX.Element; function jsx(tagOrFn: string | Component, props: unknown, _key?: string): JSX.Element { + let render: RenderFunction; if (typeof tagOrFn === 'function') { - return (tagOrFn as (props: unknown) => JSX.Element)(props); + const renderFn = (tagOrFn as (props: unknown) => JSX.Element)(props); + render = (options) => renderFn.render(options); + } else { + render = () => Promise.resolve(null); } - return () => Promise.resolve(null); + return { + render, + }; } export { jsx }; export const jsxDEV = jsx; + +export async function render(node: JSX.Element, options?: RenderOptions): Promise { + await node.render(options); +} diff --git a/packages/jsx/src/types.ts b/packages/jsx/src/types.ts index 0da9cd75..60acc040 100644 --- a/packages/jsx/src/types.ts +++ b/packages/jsx/src/types.ts @@ -1,7 +1,15 @@ +import type { CommonOptions } from '@clack/prompts'; + +export interface RenderOptions extends CommonOptions {} + +export type RenderFunction = (options?: RenderOptions) => Promise; + namespace JSX { export type IntrinsicElements = never; - export type Element = () => Promise; + export type Element = { + render: RenderFunction; + }; } export type { JSX }; diff --git a/packages/jsx/src/utils.ts b/packages/jsx/src/utils.ts index 6b96af21..b33cc2c8 100644 --- a/packages/jsx/src/utils.ts +++ b/packages/jsx/src/utils.ts @@ -1,13 +1,14 @@ -import type { JSX } from './types.js'; +import type { JSX, RenderOptions } from './types.js'; export async function resolveChildren( - children: JSX.Element[] | JSX.Element | string + children: JSX.Element[] | JSX.Element | string, + options?: RenderOptions ): Promise { const arr = Array.isArray(children) ? children : [children]; const results: unknown[] = []; for (const child of arr) { - const result = typeof child === 'string' ? child : await child(); + const result = typeof child === 'string' ? child : await child.render(options); results.push(result); } diff --git a/packages/jsx/test/components/confirm.test.tsx b/packages/jsx/test/components/confirm.test.tsx index caca6c79..12d45c33 100644 --- a/packages/jsx/test/components/confirm.test.tsx +++ b/packages/jsx/test/components/confirm.test.tsx @@ -12,7 +12,8 @@ describe('Confirm', () => { }); test('can set message', async () => { - const task = ()(); + const element = ; + const task = element.render({ input, output }); input.emit('keypress', '', { name: 'return' }); const result = await task; expect(result).to.equal(true); @@ -20,7 +21,8 @@ describe('Confirm', () => { }); test('can set active text', async () => { - const task = ()(); + const element = ; + const task = element.render({ input, output }); input.emit('keypress', '', { name: 'return' }); const result = await task; expect(result).to.equal(true); @@ -28,7 +30,8 @@ describe('Confirm', () => { }); test('can set inactive text', async () => { - const task = ()(); + const element = ; + const task = element.render({ input, output }); input.emit('keypress', '', { name: 'return' }); const result = await task; expect(result).to.equal(true); diff --git a/packages/jsx/test/components/field.test.tsx b/packages/jsx/test/components/field.test.tsx index 9ecb7c09..2d050e31 100644 --- a/packages/jsx/test/components/field.test.tsx +++ b/packages/jsx/test/components/field.test.tsx @@ -12,11 +12,12 @@ describe('Field', () => { }); test('renders and resolves children', async () => { - const task = ( + const element = ( - + - )(); + ); + const task = element.render({ input, output }); input.emit('keypress', 'a', { name: 'a' }); input.emit('keypress', 'b', { name: 'b' }); @@ -32,12 +33,13 @@ describe('Field', () => { }); test('resolves multiple children into array', async () => { - const task = ( + const element = ( - - + + - )(); + ); + const task = element.render({ input, output }); input.emit('keypress', 'a', { name: 'a' }); input.emit('keypress', '', { name: 'return' }); diff --git a/packages/jsx/test/components/form.test.tsx b/packages/jsx/test/components/form.test.tsx index 40393a56..b9844b1e 100644 --- a/packages/jsx/test/components/form.test.tsx +++ b/packages/jsx/test/components/form.test.tsx @@ -12,13 +12,14 @@ describe('Form', () => { }); test('renders and resolves object', async () => { - const task = ( + const element = (
- +
- )(); + ); + const task = element.render({ input, output }); input.emit('keypress', 'a', { name: 'a' }); input.emit('keypress', 'b', { name: 'b' }); @@ -32,16 +33,17 @@ describe('Form', () => { expect(output.buffer).toMatchSnapshot(); }); test('renders and resolves multiple fields', async () => { - const task = ( + const element = (
- + - +
- )(); + ); + const task = element.render({ input, output }); input.emit('keypress', 'a', { name: 'a' }); input.emit('keypress', '', { name: 'return' }); diff --git a/packages/jsx/test/components/note.test.tsx b/packages/jsx/test/components/note.test.tsx index 11810514..19adad01 100644 --- a/packages/jsx/test/components/note.test.tsx +++ b/packages/jsx/test/components/note.test.tsx @@ -12,25 +12,28 @@ describe('Note', () => { }); test('can render string message', async () => { - const task = ; - await task(); + const element = ; + const task = element.render({ output }); + await task; expect(output.buffer).toMatchSnapshot(); }); test('can render children as message', async () => { - const task = a message; - await task(); + const element = a message; + const task = element.render({ output }); + await task; expect(output.buffer).toMatchSnapshot(); }); test('can render complex results as message', async () => { - const task = ( - - + const element = ( + + - )(); + ); + const task = element.render({ input, output }); input.emit('keypress', '', { name: 'return' }); await task; @@ -38,12 +41,13 @@ describe('Note', () => { }); test('can render multiple children as message', async () => { - const task = ( - - - + const element = ( + + + - )(); + ); + const task = element.render({ input, output }); input.emit('keypress', '', { name: 'return' }); await nextTick(); input.emit('keypress', '', { name: 'return' }); diff --git a/packages/jsx/test/components/password.test.tsx b/packages/jsx/test/components/password.test.tsx index a5522524..9ffdb20d 100644 --- a/packages/jsx/test/components/password.test.tsx +++ b/packages/jsx/test/components/password.test.tsx @@ -12,7 +12,8 @@ describe('Password', () => { }); test('renders password input', async () => { - const task = ()(); + const element = ; + const task = element.render({ input, output }); input.emit('keypress', '', { name: 'return' }); @@ -23,7 +24,8 @@ describe('Password', () => { }); test('renders user input', async () => { - const task = ()(); + const element = ; + const task = element.render({ input, output }); input.emit('keypress', 'a', { name: 'a' }); input.emit('keypress', 'b', { name: 'b' }); @@ -36,7 +38,8 @@ describe('Password', () => { }); test('can set custom mask', async () => { - const task = ()(); + const element = ; + const task = element.render({ input, output }); input.emit('keypress', 'a', { name: 'a' }); input.emit('keypress', 'b', { name: 'b' }); diff --git a/packages/jsx/test/components/select.test.tsx b/packages/jsx/test/components/select.test.tsx index 0a98c100..c5e66717 100644 --- a/packages/jsx/test/components/select.test.tsx +++ b/packages/jsx/test/components/select.test.tsx @@ -12,12 +12,13 @@ describe('Select', () => { }); test('renders options', async () => { - const task = ( - - )(); + ); + const task = element.render({ input, output }); await nextTick(); input.emit('keypress', '', { name: 'return' }); @@ -46,8 +48,8 @@ describe('Select', () => { }); test('renders options with hints', async () => { - const task = ( - @@ -55,7 +57,8 @@ describe('Select', () => { Eight o eight - )(); + ); + const task = element.render({ input, output }); await nextTick(); input.emit('keypress', '', { name: 'return' }); diff --git a/packages/jsx/test/components/text.test.tsx b/packages/jsx/test/components/text.test.tsx index 169b050b..dc0a5f37 100644 --- a/packages/jsx/test/components/text.test.tsx +++ b/packages/jsx/test/components/text.test.tsx @@ -12,7 +12,8 @@ describe('Text', () => { }); test('renders text input', async () => { - const task = ()(); + const element = ; + const task = element.render({ input, output }); input.emit('keypress', '', { name: 'return' }); @@ -23,7 +24,8 @@ describe('Text', () => { }); test('can set placeholder', async () => { - const task = ()(); + const element = ; + const task = element.render({ input, output }); input.emit('keypress', '', { name: 'return' }); @@ -34,7 +36,8 @@ describe('Text', () => { }); test('can set default value', async () => { - const task = ()(); + const element = ; + const task = element.render({ input, output }); input.emit('keypress', '', { name: 'return' }); @@ -45,7 +48,8 @@ describe('Text', () => { }); test('can set initial value', async () => { - const task = ()(); + const element = ; + const task = element.render({ input, output }); input.emit('keypress', '', { name: 'return' }); diff --git a/packages/jsx/test/jsx.test.tsx b/packages/jsx/test/jsx.test.tsx index 0c93c9ef..34539b19 100644 --- a/packages/jsx/test/jsx.test.tsx +++ b/packages/jsx/test/jsx.test.tsx @@ -12,26 +12,26 @@ describe('jsx', () => { }); test('can render', async () => { - const task = jsx(Confirm, { + const element = jsx(Confirm, { message: 'foo?', - input, - output, - })(); + }); + const task = element.render({ input, output }); input.emit('keypress', '', { name: 'return' }); const result = await task; expect(result).to.equal(true); }); test('can render JSX', async () => { - const task = ()(); + const element = ; + const task = element.render({ input, output }); input.emit('keypress', '', { name: 'return' }); const result = await task; expect(result).to.equal(true); }); test('unknown elements are null', async () => { - const task = jsx('unknown-nonsense' as never, {} as never); - const result = await task(); + const element = jsx('unknown-nonsense' as never, {} as never); + const result = await element.render(); expect(result).to.equal(null); }); }); From 0f61366c7924e5270f25b33f7c809db807eb4967 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Sun, 13 Jul 2025 13:57:03 +0100 Subject: [PATCH 8/8] wip: loosen component type --- packages/jsx/src/index.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/jsx/src/index.ts b/packages/jsx/src/index.ts index 4a8de096..520255e5 100644 --- a/packages/jsx/src/index.ts +++ b/packages/jsx/src/index.ts @@ -34,13 +34,7 @@ export function Fragment(props: { children: JSX.Element | JSX.Element[] }): JSX. }; } -export type Component = - | typeof Confirm - | typeof Note - | typeof Text - | typeof Password - | typeof Option - | typeof Select; +export type Component = (props: never) => JSX.Element; function jsx( tag: T,