diff --git a/packages/jsx/src/components/confirm.ts b/packages/jsx/src/components/confirm.ts index 2293d80..13c6da3 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 0000000..834642c --- /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 0000000..49253c6 --- /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 94ac17d..2142dde 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 a49b839..9eec3f2 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 a41b3f1..4de77aa 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 08c2c10..8d7e6a7 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 1678a9e..495f7b5 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 5777b67..a40dc28 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 70519da..0da9cd7 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 b961998..6b96af2 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 0000000..89dc6e8 --- /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 e67dcbb..da2ac26 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 2485b1b..caca6c7 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 0000000..9ecb7c0 --- /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 9c5ba72..1181051 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 745b3c4..a552252 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 d8457ca..0a98c10 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 dd5e29d..169b050 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 87625e5..0c93c9e 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); }); });