Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions frameworks/honox/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
28 changes: 28 additions & 0 deletions frameworks/honox/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {
baseConfigs,
commonRules,
componentRules,
importConfig,
jsdoc,
} from '@formisch/eslint-config';
import { defineConfig, globalIgnores } from 'eslint/config';

export default defineConfig([
globalIgnores(['dist', 'eslint.config.js']),
{
files: ['src/**/*.{ts,tsx}'],
extends: [...baseConfigs, importConfig],
plugins: { jsdoc },
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: commonRules,
},
{
files: ['src/components/**/*.tsx'],
rules: componentRules,
},
]);
78 changes: 78 additions & 0 deletions frameworks/honox/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
{
"name": "@formisch/honox",
"description": "The lightweight, schema-first, and fully type-safe form library for hono/jsx and HonoX",
"version": "1.0.0-rc.0",
"license": "MIT",
"author": "Kanon",
"homepage": "https://formisch.dev",
"repository": {
"type": "git",
"url": "git+https://github.com/open-circle/formisch.git"
},
"keywords": [
"hono",
"honox",
"hono-form",
"form",
"forms",
"form-validation",
"validation",
"schema",
"typescript",
"type-safe",
"signals",
"bundle-size",
"modular",
"valibot"
],
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"sideEffects": false,
"files": [
"dist"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsdown",
"test": "vitest run --typecheck",
"lint": "eslint \"src/**/*.ts*\" && tsc --noEmit",
"lint.fix": "eslint \"src/**/*.ts*\" --fix",
"format": "prettier --write ./src",
"format.check": "prettier --check ./src"
},
"devDependencies": {
"@formisch/core": "workspace:*",
"@formisch/eslint-config": "workspace:*",
"@formisch/methods": "workspace:*",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.0",
"@types/node": "^24.10.1",
"@vitest/coverage-v8": "^4.1.7",
"eslint": "^9.39.1",
"hono": "4.12.32",
"jsdom": "^26.1.0",
"tsdown": "^0.16.8",
"typescript": "~5.9.3",
"valibot": "^1.4.1",
"vitest": "^4.1.7"
},
"peerDependencies": {
"hono": "^4.0.0",
"typescript": ">=5",
"valibot": "^1.4.1"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
}
92 changes: 92 additions & 0 deletions frameworks/honox/src/components/Field/Field.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { fireEvent, screen, waitFor } from '@testing-library/dom';
import { flushSync } from 'hono/jsx/dom';
import type { JSX } from 'hono/jsx/jsx-runtime';
import * as v from 'valibot';
import { describe, expect, test, vi } from 'vitest';
import { useForm } from '../../hooks/index.ts';
import type { FieldStore } from '../../types/index.ts';
import { renderHono } from '../../vitest/render.tsx';
import { Field } from './Field.tsx';

const schema = v.object({ name: v.string() });
type FormSchema = typeof schema;

describe('Field', () => {
test('should render JSX returned from children', () => {
function Test(): JSX.Element {
const form = useForm({ schema });
return (
<Field of={form} path={['name']}>
{() => <span data-testid="content">hello</span>}
</Field>
);
}

renderHono(<Test />);

expect(screen.getByTestId('content')).toHaveTextContent('hello');
});

test('should invoke children with the field store', () => {
const renderProp = vi.fn<
(field: FieldStore<FormSchema, ['name']>) => JSX.Element
>(() => <span />);

function Test(): JSX.Element {
const form = useForm({ schema, initialInput: { name: 'John' } });
return (
<Field of={form} path={['name']}>
{renderProp}
</Field>
);
}

renderHono(<Test />);

expect(renderProp).toHaveBeenCalled();
const field = renderProp.mock.lastCall![0];
expect(field.path).toEqual(['name']);
expect(field.input).toBe('John');
expect(field.props.name).toBe('["name"]');
expect(typeof field.props.onChange).toBe('function');
});

test('should re-render when the field store updates', async () => {
function Test(): JSX.Element {
const form = useForm({ schema, initialInput: { name: 'initial' } });
return (
<Field of={form} path={['name']}>
{(field) => (
<>
<input
data-testid="input"
{...field.props}
value={field.input ?? ''}
/>
<span data-testid="dirty">{String(field.isDirty)}</span>
</>
)}
</Field>
);
}

renderHono(<Test />);

const input = screen.getByTestId('input') as HTMLInputElement;
const dirty = screen.getByTestId('dirty');

expect(input.value).toBe('initial');
expect(dirty).toHaveTextContent('false');

// hono/jsx maps the `onChange` prop to a native `input` listener, the
// same normalization React applies for its synthetic `onChange`.
flushSync(() => {
fireEvent.input(input, { target: { value: 'changed' } });
});

await waitFor(() => {
expect(input.value).toBe('changed');
expect(dirty).toHaveTextContent('true');
});
});
});
48 changes: 48 additions & 0 deletions frameworks/honox/src/components/Field/Field.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {
type FormSchema,
type RequiredPath,
type ValidPath,
} from '@formisch/core/honox';
import type { JSX } from 'hono/jsx/jsx-runtime';
import type * as v from 'valibot';
import { useField } from '../../hooks/index.ts';
import type { FieldStore, FormStore } from '../../types/index.ts';

/**
* Field component props interface.
*/
export interface FieldProps<
TSchema extends FormSchema = FormSchema,
TFieldPath extends RequiredPath = RequiredPath,
> {
/**
* The form store to which the field belongs.
*/
readonly of: FormStore<TSchema>;
/**
* The path to the field within the form schema.
*/
readonly path: ValidPath<v.InferInput<TSchema>, TFieldPath>;
/**
* The render function that receives the field store and returns JSX.
*/
readonly children: (store: FieldStore<TSchema, TFieldPath>) => JSX.Element;
}

/**
* Headless form field component that provides reactive properties and state.
* The field component takes a form store, path to field, and a render function
* that receives a field store to display field state and handle user interactions.
*
* @param props The field component props.
*
* @returns The UI of the field to be rendered.
*/
// @__NO_SIDE_EFFECTS__
export function Field<
TSchema extends FormSchema,
TFieldPath extends RequiredPath,
>({ of, path, children }: FieldProps<TSchema, TFieldPath>): JSX.Element {
const field = useField(of, { path });
return children(field);
}
1 change: 1 addition & 0 deletions frameworks/honox/src/components/Field/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './Field.tsx';
86 changes: 86 additions & 0 deletions frameworks/honox/src/components/FieldArray/FieldArray.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { insert } from '@formisch/methods/honox';
import { fireEvent, screen, waitFor } from '@testing-library/dom';
import { flushSync } from 'hono/jsx/dom';
import type { JSX } from 'hono/jsx/jsx-runtime';
import * as v from 'valibot';
import { describe, expect, test, vi } from 'vitest';
import { useForm } from '../../hooks/index.ts';
import type { FieldArrayStore } from '../../types/index.ts';
import { renderHono } from '../../vitest/render.tsx';
import { FieldArray } from './FieldArray.tsx';

const schema = v.object({ items: v.array(v.string()) });
type FormSchema = typeof schema;

describe('FieldArray', () => {
test('should render JSX returned from children', () => {
function Test(): JSX.Element {
const form = useForm({ schema });
return (
<FieldArray of={form} path={['items']}>
{() => <span data-testid="content">hello</span>}
</FieldArray>
);
}

renderHono(<Test />);

expect(screen.getByTestId('content')).toHaveTextContent('hello');
});

test('should invoke children with the field array store', () => {
const renderProp = vi.fn<
(field: FieldArrayStore<FormSchema, ['items']>) => JSX.Element
>(() => <span />);

function Test(): JSX.Element {
const form = useForm({ schema, initialInput: { items: ['a', 'b'] } });
return (
<FieldArray of={form} path={['items']}>
{renderProp}
</FieldArray>
);
}

renderHono(<Test />);

expect(renderProp).toHaveBeenCalled();
const field = renderProp.mock.lastCall![0];
expect(field.path).toEqual(['items']);
expect(field.items).toHaveLength(2);
expect(field.isValid).toBe(true);
});

test('should re-render when the field array store updates', async () => {
function Test(): JSX.Element {
const form = useForm({ schema, initialInput: { items: ['a', 'b'] } });
return (
<div>
<button
type="button"
onClick={() => insert(form, { path: ['items'], initialInput: 'c' })}
>
Add
</button>
<FieldArray of={form} path={['items']}>
{(field) => <span data-testid="count">{field.items.length}</span>}
</FieldArray>
</div>
);
}

renderHono(<Test />);

const count = screen.getByTestId('count');

expect(count).toHaveTextContent('2');

flushSync(() => {
fireEvent.click(screen.getByText('Add'));
});

await waitFor(() => {
expect(count).toHaveTextContent('3');
});
});
});
Loading
Loading