mirror of
https://github.com/LukeHagar/form.git
synced 2025-12-10 04:19:54 +00:00
feat: Add Vue adapter (#416)
* chore: initial work at scaffolding Vue package * chore: initial work on adding in useField and useForm API * chore: fix build for Vue package * chore: migrate to slots for functional comps * chore: got initial fields rendering * chore: add component names for debuggability * chore: fix error regarding passdown props * chore: fix Promise constructor error in demo * chore: initial work at writing vue store implementation * feat: add initial useStore and Subscribe instances * fix: state is now passed as a dedicated reactive option * chore: temporarily remove Vue 2 typechecking * chore: make Provider and selector optional * chore: add createFormFactory * chore: attempt 1 of test - JSX * chore: attempt 2 of test - Vue JSX * chore: attempt 3 of test - H * chore: migrate to proper h function * chore: fix tests by bumping package * chore: fix JSX typings * chore: add another test for useForm * chore: listen for fieldAPIs to update * fix: fields should now update during mount * chore: add test for useField in Vue * test: add useField Vue tests * docs: add early docs for Vue package
This commit is contained in:
@@ -93,6 +93,50 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"framework": "vue",
|
||||||
|
"menuItems": [
|
||||||
|
{
|
||||||
|
"label": "Getting Started",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"label": "Quick Start",
|
||||||
|
"to": "framework/vue/quick-start"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "API Reference",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"label": "useForm",
|
||||||
|
"to": "framework/vue/reference/useForm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "useField",
|
||||||
|
"to": "framework/vue/reference/useField"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Field",
|
||||||
|
"to": "framework/vue/reference/Field"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "FormApi",
|
||||||
|
"to": "framework/vue/reference/formApi"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Examples",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"label": "Simple",
|
||||||
|
"to": "framework/vue/examples/simple"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
46
docs/framework/vue/quick-start.md
Normal file
46
docs/framework/vue/quick-start.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
id: quick-start
|
||||||
|
title: Quick Start
|
||||||
|
---
|
||||||
|
|
||||||
|
The bare minimum to get started with TanStack Form is to create a form and add a field. Keep in mind that this example does not include any validation or error handling... yet.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- App.vue -->
|
||||||
|
<script setup>
|
||||||
|
import { useForm } from '@tanstack/vue-form'
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
// Memoize your default values to prevent re-renders
|
||||||
|
defaultValues: {
|
||||||
|
fullName: '',
|
||||||
|
},
|
||||||
|
onSubmit: async (values) => {
|
||||||
|
// Do something with form data
|
||||||
|
console.log(values)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
form.provideFormContext()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<form.Field name="fullName">
|
||||||
|
<template v-slot="field">
|
||||||
|
<input
|
||||||
|
:name="field.name"
|
||||||
|
:value="field.state.value"
|
||||||
|
:onBlur="field.handleBlur"
|
||||||
|
:onChange="(e) => field.handleChange(e.target.value)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</form.Field>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
From here, you'll be ready to explore all of the other features of TanStack Form!
|
||||||
6
docs/framework/vue/reference/Field.md
Normal file
6
docs/framework/vue/reference/Field.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
id: field
|
||||||
|
title: Field
|
||||||
|
---
|
||||||
|
|
||||||
|
Please see [/packages/vue-form/src/useField.tsx](https://github.com/TanStack/form/blob/main/packages/vue-form/src/useField.tsx)
|
||||||
36
docs/framework/vue/reference/createFormFactory.md
Normal file
36
docs/framework/vue/reference/createFormFactory.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
id: createFormFactory
|
||||||
|
title: createFormFactory
|
||||||
|
---
|
||||||
|
|
||||||
|
### `createFormFactory`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export function createFormFactory<TFormData>(
|
||||||
|
opts?: FormOptions<TFormData>,
|
||||||
|
): FormFactory<TFormData>
|
||||||
|
```
|
||||||
|
|
||||||
|
A function that creates a new `FormFactory<TFormData>` instance.
|
||||||
|
|
||||||
|
- `opts`
|
||||||
|
- Optional form options and a `listen` function to be called with the form state.
|
||||||
|
|
||||||
|
### `FormFactory<TFormData>`
|
||||||
|
|
||||||
|
A type representing a form factory. Form factories provide a type-safe way to interact with the form API as opposed to using the globally exported form utilities.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export type FormFactory<TFormData> = {
|
||||||
|
useForm: (opts?: FormOptions<TFormData>) => FormApi<TFormData>
|
||||||
|
useField: UseField<TFormData>
|
||||||
|
Field: FieldComponent<TFormData>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `useForm`
|
||||||
|
- A custom composition that creates and returns a new instance of the `FormApi<TFormData>` class.
|
||||||
|
- `useField`
|
||||||
|
- A custom composition that returns an instance of the `FieldApi<TFormData>` class.
|
||||||
|
- `Field`
|
||||||
|
- A form field component.
|
||||||
6
docs/framework/vue/reference/fieldApi.md
Normal file
6
docs/framework/vue/reference/fieldApi.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
id: fieldApi
|
||||||
|
title: Field API
|
||||||
|
---
|
||||||
|
|
||||||
|
Please see [/packages/vue-form/src/useField.tsx](https://github.com/TanStack/form/blob/main/packages/vue-form/src/useField.tsx)
|
||||||
6
docs/framework/vue/reference/formApi.md
Normal file
6
docs/framework/vue/reference/formApi.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
id: formApi
|
||||||
|
title: Form API
|
||||||
|
---
|
||||||
|
|
||||||
|
Please see [/packages/vue-form/src/useForm.tsx](https://github.com/TanStack/form/blob/main/packages/vue-form/src/useForm.tsx)
|
||||||
6
docs/framework/vue/reference/useField.md
Normal file
6
docs/framework/vue/reference/useField.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
id: useField
|
||||||
|
title: useField
|
||||||
|
---
|
||||||
|
|
||||||
|
Please see [/packages/vue-form/src/useField.tsx](https://github.com/TanStack/form/blob/main/packages/vue-form/src/useField.tsx)
|
||||||
6
docs/framework/vue/reference/useForm.md
Normal file
6
docs/framework/vue/reference/useForm.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
id: useForm
|
||||||
|
title: useForm
|
||||||
|
---
|
||||||
|
|
||||||
|
Please see [/packages/vue-form/src/useForm.tsx](https://github.com/TanStack/form/blob/main/packages/vue-form/src/useForm.tsx)
|
||||||
9
examples/vue/simple/.gitignore
vendored
Normal file
9
examples/vue/simple/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
pnpm-lock.yaml
|
||||||
6
examples/vue/simple/README.md
Normal file
6
examples/vue/simple/README.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Basic example
|
||||||
|
|
||||||
|
To run this example:
|
||||||
|
|
||||||
|
- `npm install` or `yarn` or `pnpm i`
|
||||||
|
- `npm run dev` or `yarn dev` or `pnpm dev`
|
||||||
12
examples/vue/simple/index.html
Normal file
12
examples/vue/simple/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Vue Form Example</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
19
examples/vue/simple/package.json
Normal file
19
examples/vue/simple/package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "@tanstack/form-example-vue-simple",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"build:dev": "vite build -m development",
|
||||||
|
"serve": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/vue-form": "workspace:*",
|
||||||
|
"vue": "^3.3.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^4.2.3",
|
||||||
|
"typescript": "^5.0.4",
|
||||||
|
"vite": "^4.4.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
83
examples/vue/simple/src/App.vue
Normal file
83
examples/vue/simple/src/App.vue
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useForm } from '@tanstack/vue-form'
|
||||||
|
import FieldInfo from './FieldInfo.vue'
|
||||||
|
import { provideFormContext } from '@tanstack/vue-form/src'
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
defaultValues: {
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
},
|
||||||
|
onSubmit: async (values) => {
|
||||||
|
// Do something with form data
|
||||||
|
alert(JSON.stringify(values))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
form.provideFormContext()
|
||||||
|
|
||||||
|
async function onChangeFirstName(value) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||||
|
return value.includes(`error`) && `No 'error' allowed in first name`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form
|
||||||
|
@submit="
|
||||||
|
(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
void form.handleSubmit()
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<form.Field
|
||||||
|
name="firstName"
|
||||||
|
:onChange="
|
||||||
|
(value) =>
|
||||||
|
!value
|
||||||
|
? `A first name is required`
|
||||||
|
: value.length < 3
|
||||||
|
? `First name must be at least 3 characters`
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
:onChangeAsyncDebounceMs="500"
|
||||||
|
:onChangeAsync="onChangeFirstName"
|
||||||
|
>
|
||||||
|
<template v-slot="field, state">
|
||||||
|
<label :htmlFor="field.name">First Name:</label>
|
||||||
|
<input
|
||||||
|
:name="field.name"
|
||||||
|
:value="field.state.value"
|
||||||
|
@input="(e) => field.handleChange(e.target.value)"
|
||||||
|
@blur="field.handleBlur"
|
||||||
|
/>
|
||||||
|
<FieldInfo :state="state" />
|
||||||
|
</template>
|
||||||
|
</form.Field>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<form.Field name="lastName">
|
||||||
|
<template v-slot="field, state">
|
||||||
|
<label :htmlFor="field.name">Last Name:</label>
|
||||||
|
<input
|
||||||
|
:name="field.name"
|
||||||
|
:value="field.state.value"
|
||||||
|
@input="(e) => field.handleChange(e.target.value)"
|
||||||
|
@blur="field.handleBlur"
|
||||||
|
/>
|
||||||
|
<FieldInfo :state="state" />
|
||||||
|
</template>
|
||||||
|
</form.Field>
|
||||||
|
</div>
|
||||||
|
<form.Subscribe>
|
||||||
|
<template v-slot="{ canSubmit, isSubmitting }">
|
||||||
|
<button type="submit" :disabled="!canSubmit">
|
||||||
|
{{ isSubmitting ? '...' : 'Submit' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</form.Subscribe>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
14
examples/vue/simple/src/FieldInfo.vue
Normal file
14
examples/vue/simple/src/FieldInfo.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { FieldApi } from '@tanstack/vue-form'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
state: FieldApi<any, any>['state']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<em v-if="props.state.meta.touchedError">{{
|
||||||
|
props.state.meta.touchedError
|
||||||
|
}}</em>
|
||||||
|
{{ props.state.meta.isValidating ? 'Validating...' : null }}
|
||||||
|
</template>
|
||||||
5
examples/vue/simple/src/main.ts
Normal file
5
examples/vue/simple/src/main.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
5
examples/vue/simple/src/shims-vue.d.ts
vendored
Normal file
5
examples/vue/simple/src/shims-vue.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
declare module '*.vue' {
|
||||||
|
import { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
6
examples/vue/simple/src/types.d.ts
vendored
Normal file
6
examples/vue/simple/src/types.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export interface Post {
|
||||||
|
userId: number
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
}
|
||||||
15
examples/vue/simple/tsconfig.json
Normal file
15
examples/vue/simple/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "esnext",
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"sourceMap": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"lib": ["esnext", "dom"],
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
|
||||||
|
}
|
||||||
10
examples/vue/simple/vite.config.ts
Normal file
10
examples/vue/simple/vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import createVuePlugin from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [createVuePlugin()],
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ['@tanstack/vue-query', 'vue-demi'],
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -44,6 +44,7 @@
|
|||||||
"@testing-library/react": "^14.0.0",
|
"@testing-library/react": "^14.0.0",
|
||||||
"@testing-library/react-hooks": "^8.0.1",
|
"@testing-library/react-hooks": "^8.0.1",
|
||||||
"@testing-library/user-event": "^14.4.3",
|
"@testing-library/user-event": "^14.4.3",
|
||||||
|
"@testing-library/vue": "^7.0.0",
|
||||||
"@types/jest": "^26.0.4",
|
"@types/jest": "^26.0.4",
|
||||||
"@types/luxon": "^2.3.1",
|
"@types/luxon": "^2.3.1",
|
||||||
"@types/node": "^17.0.25",
|
"@types/node": "^17.0.25",
|
||||||
@@ -53,6 +54,8 @@
|
|||||||
"@types/testing-library__jest-dom": "^5.14.5",
|
"@types/testing-library__jest-dom": "^5.14.5",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.4.1",
|
"@typescript-eslint/eslint-plugin": "^6.4.1",
|
||||||
"@typescript-eslint/parser": "^6.4.1",
|
"@typescript-eslint/parser": "^6.4.1",
|
||||||
|
"@vitejs/plugin-vue": "^4.3.4",
|
||||||
|
"@vitejs/plugin-vue-jsx": "^3.0.2",
|
||||||
"@vitest/coverage-istanbul": "^0.34.3",
|
"@vitest/coverage-istanbul": "^0.34.3",
|
||||||
"axios": "^0.26.1",
|
"axios": "^0.26.1",
|
||||||
"babel-eslint": "^10.1.0",
|
"babel-eslint": "^10.1.0",
|
||||||
@@ -84,6 +87,7 @@
|
|||||||
"react-dom-17": "npm:react-dom@^17.0.2",
|
"react-dom-17": "npm:react-dom@^17.0.2",
|
||||||
"rimraf": "^5.0.1",
|
"rimraf": "^5.0.1",
|
||||||
"rollup": "^3.23.0",
|
"rollup": "^3.23.0",
|
||||||
|
"rollup-plugin-esbuild": "^5.0.0",
|
||||||
"rollup-plugin-node-externals": "^6.1.0",
|
"rollup-plugin-node-externals": "^6.1.0",
|
||||||
"rollup-plugin-preserve-directives": "^0.2.0",
|
"rollup-plugin-preserve-directives": "^0.2.0",
|
||||||
"rollup-plugin-size": "^0.2.2",
|
"rollup-plugin-size": "^0.2.2",
|
||||||
@@ -97,7 +101,7 @@
|
|||||||
"type-fest": "^3.11.0",
|
"type-fest": "^3.11.0",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"vitest": "^0.34.3",
|
"vitest": "^0.34.3",
|
||||||
"vue": "^3.2.47"
|
"vue": "^3.3.4"
|
||||||
},
|
},
|
||||||
"bundlewatch": {
|
"bundlewatch": {
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
11
packages/vue-form/.eslintrc.cjs
Normal file
11
packages/vue-form/.eslintrc.cjs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// @ts-check
|
||||||
|
|
||||||
|
/** @type {import('eslint').Linter.Config} */
|
||||||
|
const config = {
|
||||||
|
parserOptions: {
|
||||||
|
tsconfigRootDir: __dirname,
|
||||||
|
project: './tsconfig.json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = config
|
||||||
35
packages/vue-form/README.md
Normal file
35
packages/vue-form/README.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<img src="https://static.scarf.sh/a.png?x-pxid=be2d8a11-9712-4c1d-9963-580b2d4fb133" />
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Hooks for managing form state in Vue
|
||||||
|
|
||||||
|
<a href="https://twitter.com/intent/tweet?button_hashtag=TanStack" target="\_parent">
|
||||||
|
<img alt="#TanStack" src="https://img.shields.io/twitter/url?color=%2308a0e9&label=%23TanStack&style=social&url=https%3A%2F%2Ftwitter.com%2Fintent%2Ftweet%3Fbutton_hashtag%3DTanStack">
|
||||||
|
</a><a href="https://discord.com/invite/WrRKjPJ" target="\_parent">
|
||||||
|
<img alt="" src="https://img.shields.io/badge/Discord-TanStack-%235865F2" />
|
||||||
|
</a><a href="https://github.com/TanStack/form/actions?query=workflow%3A%22vue-form+tests%22">
|
||||||
|
<img src="https://github.com/TanStack/form/workflows/vue-form%20tests/badge.svg" />
|
||||||
|
</a><a href="https://www.npmjs.com/package/@tanstack/form-core" target="\_parent">
|
||||||
|
<img alt="" src="https://img.shields.io/npm/dm/@tanstack/form-core.svg" />
|
||||||
|
</a><a href="https://bundlephobia.com/package/@tanstack/vue-form@latest" target="\_parent">
|
||||||
|
<img alt="" src="https://badgen.net/bundlephobia/minzip/@tanstack/vue-form" />
|
||||||
|
</a><a href="#badge">
|
||||||
|
<img alt="semantic-release" src="https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg">
|
||||||
|
</a><a href="https://github.com/TanStack/form/discussions">
|
||||||
|
<img alt="Join the discussion on Github" src="https://img.shields.io/badge/Github%20Discussions%20%26%20Support-Chat%20now!-blue" />
|
||||||
|
</a><a href="https://bestofjs.org/projects/tanstack-form"><img alt="Best of JS" src="https://img.shields.io/endpoint?url=https://bestofjs-serverless.now.sh/api/project-badge?fullName=TanStack%form%26since=daily" /></a><a href="https://github.com/TanStack/form/" target="\_parent">
|
||||||
|
<img alt="" src="https://img.shields.io/github/stars/TanStack/form.svg?style=social&label=Star" />
|
||||||
|
</a><a href="https://twitter.com/tannerlinsley" target="\_parent">
|
||||||
|
<img alt="" src="https://img.shields.io/twitter/follow/tannerlinsley.svg?style=social&label=Follow" />
|
||||||
|
</a> <a href="https://gitpod.io/from-referrer/">
|
||||||
|
<img src="https://img.shields.io/badge/Gitpod-Ready--to--Code-blue?logo=gitpod" alt="Gitpod Ready-to-Code"/>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
Enjoy this library? Try the entire [TanStack](https://tanstack.com)! [TanStack Table](https://github.com/TanStack/table), [TanStack Router](https://github.com/tanstack/router), [TanStack Virtual](https://github.com/tanstack/virtual), [React Charts](https://github.com/TanStack/react-charts), [React Ranger](https://github.com/TanStack/ranger)
|
||||||
|
|
||||||
|
## Visit [tanstack.com/form](https://tanstack.com/form) for docs, guides, API and more!
|
||||||
|
|
||||||
|
### [Become a Sponsor!](https://github.com/sponsors/tannerlinsley/)
|
||||||
|
|
||||||
|
<!-- Use the force, Luke -->
|
||||||
68
packages/vue-form/package.json
Normal file
68
packages/vue-form/package.json
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"name": "@tanstack/vue-form",
|
||||||
|
"version": "0.0.12",
|
||||||
|
"description": "Powerful, type-safe forms for Vue.",
|
||||||
|
"author": "tannerlinsley",
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": "tanstack/form",
|
||||||
|
"homepage": "https://tanstack.com/form",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"types": "build/lib/index.d.ts",
|
||||||
|
"main": "build/lib/index.legacy.cjs",
|
||||||
|
"module": "build/lib/index.legacy.js",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./build/lib/index.d.ts",
|
||||||
|
"import": "./build/lib/index.js",
|
||||||
|
"require": "./build/lib/index.cjs",
|
||||||
|
"default": "./build/lib/index.cjs"
|
||||||
|
},
|
||||||
|
"./package.json": "./package.json"
|
||||||
|
},
|
||||||
|
"sideEffects": false,
|
||||||
|
"scripts": {
|
||||||
|
"clean": "rimraf ./build && rimraf ./coverage",
|
||||||
|
"test:eslint": "eslint --ext .ts,.tsx ./src",
|
||||||
|
"test:types": "tsc",
|
||||||
|
"fixme:test:lib": "pnpm run test:2 && pnpm run test:2.7 && pnpm run test:3",
|
||||||
|
"test:lib": "pnpm run test:3",
|
||||||
|
"test:2": "vue-demi-switch 2 vue2 && vitest",
|
||||||
|
"test:2.7": "vue-demi-switch 2.7 vue2.7 && vitest",
|
||||||
|
"test:3": "vue-demi-switch 3 && vitest",
|
||||||
|
"test:lib:dev": "pnpm run test:lib --watch",
|
||||||
|
"test:build": "publint --strict",
|
||||||
|
"build": "pnpm build:rollup && pnpm build:codemods && pnpm build:types",
|
||||||
|
"build:rollup": "rollup --config rollup.config.js",
|
||||||
|
"build:codemods": "cpy ../codemods/src/**/* ./build/codemods",
|
||||||
|
"build:types": "tsc --emitDeclarationOnly"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"build",
|
||||||
|
"src"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/form-core": "workspace:*",
|
||||||
|
"@tanstack/store": "0.1.3",
|
||||||
|
"@tanstack/vue-store": "0.1.3",
|
||||||
|
"vue-demi": "^0.14.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vue/composition-api": "1.7.2",
|
||||||
|
"vue": "^3.3.4",
|
||||||
|
"vue2": "npm:vue@2.6",
|
||||||
|
"vue2.7": "npm:vue@2.7"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@vue/composition-api": "^1.1.2",
|
||||||
|
"vue": "^2.5.0 || ^3.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@vue/composition-api": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
packages/vue-form/rollup.config.js
Normal file
12
packages/vue-form/rollup.config.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// @ts-check
|
||||||
|
|
||||||
|
import { defineConfig } from 'rollup'
|
||||||
|
import { buildConfigs } from '../../scripts/getRollupConfig.js'
|
||||||
|
|
||||||
|
export default defineConfig(
|
||||||
|
buildConfigs({
|
||||||
|
name: 'vue-form',
|
||||||
|
outputFile: 'index',
|
||||||
|
entryFile: './src/index.ts',
|
||||||
|
}),
|
||||||
|
)
|
||||||
23
packages/vue-form/src/createFormFactory.ts
Normal file
23
packages/vue-form/src/createFormFactory.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { FormApi, FormOptions } from '@tanstack/form-core'
|
||||||
|
|
||||||
|
import { type UseField, type FieldComponent, Field, useField } from './useField'
|
||||||
|
import { useForm } from './useForm'
|
||||||
|
|
||||||
|
export type FormFactory<TFormData> = {
|
||||||
|
useForm: (opts?: FormOptions<TFormData>) => FormApi<TFormData>
|
||||||
|
useField: UseField<TFormData>
|
||||||
|
Field: FieldComponent<TFormData, TFormData>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFormFactory<TFormData>(
|
||||||
|
defaultOpts?: FormOptions<TFormData>,
|
||||||
|
): FormFactory<TFormData> {
|
||||||
|
return {
|
||||||
|
useForm: (opts) => {
|
||||||
|
const formOptions = Object.assign({}, defaultOpts, opts)
|
||||||
|
return useForm<TFormData>(formOptions)
|
||||||
|
},
|
||||||
|
useField: useField as any,
|
||||||
|
Field: Field as any,
|
||||||
|
}
|
||||||
|
}
|
||||||
23
packages/vue-form/src/formContext.ts
Normal file
23
packages/vue-form/src/formContext.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { FormApi } from '@tanstack/form-core'
|
||||||
|
import { inject, provide } from 'vue-demi'
|
||||||
|
|
||||||
|
export type FormContext = {
|
||||||
|
formApi: FormApi<any>
|
||||||
|
parentFieldName?: string
|
||||||
|
} | null
|
||||||
|
|
||||||
|
export const formContext = Symbol('FormContext')
|
||||||
|
|
||||||
|
export function provideFormContext(val: FormContext) {
|
||||||
|
provide(formContext, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFormContext() {
|
||||||
|
const formApi = inject(formContext) as FormContext
|
||||||
|
|
||||||
|
if (!formApi) {
|
||||||
|
throw new Error(`You are trying to use the form API outside of a form!`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return formApi
|
||||||
|
}
|
||||||
5
packages/vue-form/src/index.ts
Normal file
5
packages/vue-form/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export * from '@tanstack/form-core'
|
||||||
|
export * from './createFormFactory'
|
||||||
|
export * from './formContext'
|
||||||
|
export * from './useField'
|
||||||
|
export * from './useForm'
|
||||||
1
packages/vue-form/src/sfc.d.ts
vendored
Normal file
1
packages/vue-form/src/sfc.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
declare module '*.vue'
|
||||||
240
packages/vue-form/src/tests/useField.test.tsx
Normal file
240
packages/vue-form/src/tests/useField.test.tsx
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
/// <reference lib="dom" />
|
||||||
|
import { h, defineComponent } from 'vue-demi'
|
||||||
|
import { render, waitFor } from '@testing-library/vue'
|
||||||
|
import '@testing-library/jest-dom'
|
||||||
|
import {
|
||||||
|
createFormFactory,
|
||||||
|
type FieldApi,
|
||||||
|
provideFormContext,
|
||||||
|
useForm,
|
||||||
|
} from '../index'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import * as React from 'react'
|
||||||
|
import { sleep } from './utils'
|
||||||
|
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
describe('useField', () => {
|
||||||
|
it('should allow to set default value', async () => {
|
||||||
|
type Person = {
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const formFactory = createFormFactory<Person>()
|
||||||
|
|
||||||
|
const Comp = defineComponent(() => {
|
||||||
|
const form = formFactory.useForm()
|
||||||
|
|
||||||
|
provideFormContext({ formApi: form })
|
||||||
|
|
||||||
|
return () => (
|
||||||
|
<form.Field name="firstName" defaultValue="FirstName">
|
||||||
|
{(field: FieldApi<string, Person>) => (
|
||||||
|
<input
|
||||||
|
data-testid={'fieldinput'}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onInput={(e) =>
|
||||||
|
field.handleChange((e.target as HTMLInputElement).value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const { getByTestId } = render(Comp)
|
||||||
|
const input = getByTestId('fieldinput')
|
||||||
|
await waitFor(() => expect(input).toHaveValue('FirstName'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not validate on change if isTouched is false', async () => {
|
||||||
|
type Person = {
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
}
|
||||||
|
const error = 'Please enter a different value'
|
||||||
|
|
||||||
|
const formFactory = createFormFactory<Person>()
|
||||||
|
|
||||||
|
const Comp = defineComponent(() => {
|
||||||
|
const form = formFactory.useForm()
|
||||||
|
|
||||||
|
provideFormContext({ formApi: form })
|
||||||
|
|
||||||
|
return () => (
|
||||||
|
<form.Field
|
||||||
|
name="firstName"
|
||||||
|
onChange={(value) => (value === 'other' ? error : undefined)}
|
||||||
|
>
|
||||||
|
{(field: FieldApi<string, Person>) => (
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
data-testid="fieldinput"
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onInput={(e) =>
|
||||||
|
field.setValue((e.target as HTMLInputElement).value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p>{field.getMeta().error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const { getByTestId, queryByText } = render(Comp)
|
||||||
|
const input = getByTestId('fieldinput')
|
||||||
|
await user.type(input, 'other')
|
||||||
|
expect(queryByText(error)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should validate on change if isTouched is true', async () => {
|
||||||
|
type Person = {
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
}
|
||||||
|
const error = 'Please enter a different value'
|
||||||
|
|
||||||
|
const formFactory = createFormFactory<Person>()
|
||||||
|
|
||||||
|
const Comp = defineComponent(() => {
|
||||||
|
const form = formFactory.useForm()
|
||||||
|
|
||||||
|
provideFormContext({ formApi: form })
|
||||||
|
|
||||||
|
return () => (
|
||||||
|
<form.Field
|
||||||
|
name="firstName"
|
||||||
|
onChange={(value) => (value === 'other' ? error : undefined)}
|
||||||
|
>
|
||||||
|
{(field: FieldApi<string, Person>) => (
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
data-testid="fieldinput"
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onInput={(e) =>
|
||||||
|
field.handleChange((e.target as HTMLInputElement).value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p>{field.getMeta().error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const { getByTestId, getByText, queryByText } = render(Comp)
|
||||||
|
const input = getByTestId('fieldinput')
|
||||||
|
expect(queryByText(error)).not.toBeInTheDocument()
|
||||||
|
await user.type(input, 'other')
|
||||||
|
expect(getByText(error)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should validate async on change', async () => {
|
||||||
|
type Person = {
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
}
|
||||||
|
const error = 'Please enter a different value'
|
||||||
|
|
||||||
|
const formFactory = createFormFactory<Person>()
|
||||||
|
|
||||||
|
const Comp = defineComponent(() => {
|
||||||
|
const form = formFactory.useForm()
|
||||||
|
|
||||||
|
provideFormContext({ formApi: form })
|
||||||
|
|
||||||
|
return () => (
|
||||||
|
<form.Field
|
||||||
|
name="firstName"
|
||||||
|
defaultMeta={{ isTouched: true }}
|
||||||
|
onChangeAsync={async () => {
|
||||||
|
await sleep(10)
|
||||||
|
return error
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(field: FieldApi<string, Person>) => (
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
data-testid="fieldinput"
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onInput={(e) =>
|
||||||
|
field.handleChange((e.target as HTMLInputElement).value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p>{field.getMeta().error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const { getByTestId, getByText, queryByText } = render(Comp)
|
||||||
|
const input = getByTestId('fieldinput')
|
||||||
|
expect(queryByText(error)).not.toBeInTheDocument()
|
||||||
|
await user.type(input, 'other')
|
||||||
|
await waitFor(() => getByText(error))
|
||||||
|
expect(getByText(error)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should validate async on change with debounce', async () => {
|
||||||
|
type Person = {
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockFn = vi.fn()
|
||||||
|
const error = 'Please enter a different value'
|
||||||
|
const formFactory = createFormFactory<Person>()
|
||||||
|
|
||||||
|
const Comp = defineComponent(() => {
|
||||||
|
const form = formFactory.useForm()
|
||||||
|
|
||||||
|
provideFormContext({ formApi: form })
|
||||||
|
|
||||||
|
return () => (
|
||||||
|
<form.Field
|
||||||
|
name="firstName"
|
||||||
|
defaultMeta={{ isTouched: true }}
|
||||||
|
onChangeAsyncDebounceMs={100}
|
||||||
|
onChangeAsync={async () => {
|
||||||
|
mockFn()
|
||||||
|
await sleep(10)
|
||||||
|
return error
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(field: FieldApi<string, Person>) => (
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
data-testid="fieldinput"
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onInput={(e) =>
|
||||||
|
field.handleChange((e.target as HTMLInputElement).value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p>{field.getMeta().error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const { getByTestId, getByText } = render(<Comp />)
|
||||||
|
const input = getByTestId('fieldinput')
|
||||||
|
await user.type(input, 'other')
|
||||||
|
// mockFn will have been called 5 times without onChangeAsyncDebounceMs
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(0)
|
||||||
|
await waitFor(() => getByText(error))
|
||||||
|
expect(getByText(error)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
130
packages/vue-form/src/tests/useForm.test.tsx
Normal file
130
packages/vue-form/src/tests/useForm.test.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/// <reference lib="dom" />
|
||||||
|
import { h, defineComponent, ref } from 'vue-demi'
|
||||||
|
import { render } from '@testing-library/vue'
|
||||||
|
import '@testing-library/jest-dom'
|
||||||
|
import {
|
||||||
|
createFormFactory,
|
||||||
|
type FieldApi,
|
||||||
|
provideFormContext,
|
||||||
|
useForm,
|
||||||
|
} from '../index'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import * as React from 'react'
|
||||||
|
import { waitFor } from '@testing-library/react'
|
||||||
|
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
describe('useForm', () => {
|
||||||
|
it('preserved field state', async () => {
|
||||||
|
type Person = {
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const formFactory = createFormFactory<Person>()
|
||||||
|
|
||||||
|
const Comp = defineComponent(() => {
|
||||||
|
const form = formFactory.useForm()
|
||||||
|
|
||||||
|
provideFormContext({ formApi: form })
|
||||||
|
|
||||||
|
return () => (
|
||||||
|
<form.Field name="firstName" defaultValue="">
|
||||||
|
{(field: FieldApi<string, Person>) => (
|
||||||
|
<input
|
||||||
|
data-testid={'fieldinput'}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onInput={(e) =>
|
||||||
|
field.handleChange((e.target as HTMLInputElement).value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const { getByTestId, queryByText } = render(Comp)
|
||||||
|
const input = getByTestId('fieldinput')
|
||||||
|
expect(queryByText('FirstName')).not.toBeInTheDocument()
|
||||||
|
await user.type(input, 'FirstName')
|
||||||
|
expect(input).toHaveValue('FirstName')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should allow default values to be set', async () => {
|
||||||
|
type Person = {
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const formFactory = createFormFactory<Person>()
|
||||||
|
|
||||||
|
const Comp = defineComponent(() => {
|
||||||
|
const form = formFactory.useForm({
|
||||||
|
defaultValues: {
|
||||||
|
firstName: 'FirstName',
|
||||||
|
lastName: 'LastName',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
form.provideFormContext()
|
||||||
|
|
||||||
|
return () => (
|
||||||
|
<form.Field name="firstName" defaultValue="">
|
||||||
|
{(field: FieldApi<string, Person>) => <p>{field.state.value}</p>}
|
||||||
|
</form.Field>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const { findByText, queryByText } = render(Comp)
|
||||||
|
expect(await findByText('FirstName')).toBeInTheDocument()
|
||||||
|
expect(queryByText('LastName')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle submitting properly', async () => {
|
||||||
|
const Comp = defineComponent(() => {
|
||||||
|
const submittedData = ref<{ firstName: string }>()
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
defaultValues: {
|
||||||
|
firstName: 'FirstName',
|
||||||
|
},
|
||||||
|
onSubmit: (data) => {
|
||||||
|
submittedData.value = data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
form.provideFormContext()
|
||||||
|
|
||||||
|
return () => (
|
||||||
|
<form.Provider>
|
||||||
|
<form.Field name="firstName">
|
||||||
|
{(field: FieldApi<string, { firstName: string }>) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.handleChange((e.target as HTMLInputElement).value)
|
||||||
|
}
|
||||||
|
placeholder={'First name'}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</form.Field>
|
||||||
|
<button onClick={form.handleSubmit}>Submit</button>
|
||||||
|
{submittedData.value && (
|
||||||
|
<p>Submitted data: {submittedData.value.firstName}</p>
|
||||||
|
)}
|
||||||
|
</form.Provider>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const { findByPlaceholderText, getByText } = render(Comp)
|
||||||
|
const input = await findByPlaceholderText('First name')
|
||||||
|
await user.clear(input)
|
||||||
|
await user.type(input, 'OtherName')
|
||||||
|
await user.click(getByText('Submit'))
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(getByText('Submitted data: OtherName')).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
5
packages/vue-form/src/tests/utils.ts
Normal file
5
packages/vue-form/src/tests/utils.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export function sleep(timeout: number): Promise<void> {
|
||||||
|
return new Promise((resolve, _reject) => {
|
||||||
|
setTimeout(resolve, timeout)
|
||||||
|
})
|
||||||
|
}
|
||||||
1
packages/vue-form/src/types.ts
Normal file
1
packages/vue-form/src/types.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export type NoInfer<T> = [T][T extends any ? 0 : never]
|
||||||
151
packages/vue-form/src/useField.tsx
Normal file
151
packages/vue-form/src/useField.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import {
|
||||||
|
type DeepKeys,
|
||||||
|
type DeepValue,
|
||||||
|
FieldApi,
|
||||||
|
type FieldOptions,
|
||||||
|
type Narrow,
|
||||||
|
} from '@tanstack/form-core'
|
||||||
|
import { useStore } from '@tanstack/vue-store'
|
||||||
|
import {
|
||||||
|
type SetupContext,
|
||||||
|
defineComponent,
|
||||||
|
type Ref,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
watch,
|
||||||
|
} from 'vue-demi'
|
||||||
|
import { provideFormContext, useFormContext } from './formContext'
|
||||||
|
|
||||||
|
declare module '@tanstack/form-core' {
|
||||||
|
// eslint-disable-next-line no-shadow
|
||||||
|
interface FieldApi<TData, TFormData> {
|
||||||
|
Field: FieldComponent<TData, TFormData>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseFieldOptions<TData, TFormData>
|
||||||
|
extends FieldOptions<TData, TFormData> {
|
||||||
|
mode?: 'value' | 'array'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseField<TFormData> = <TField extends DeepKeys<TFormData>>(
|
||||||
|
opts?: { name: Narrow<TField> } & UseFieldOptions<
|
||||||
|
DeepValue<TFormData, TField>,
|
||||||
|
TFormData
|
||||||
|
>,
|
||||||
|
) => FieldApi<DeepValue<TFormData, TField>, TFormData>
|
||||||
|
|
||||||
|
export function useField<TData, TFormData>(
|
||||||
|
opts: UseFieldOptions<TData, TFormData>,
|
||||||
|
): {
|
||||||
|
api: FieldApi<TData, TFormData>
|
||||||
|
state: Readonly<Ref<FieldApi<TData, TFormData>['state']>>
|
||||||
|
} {
|
||||||
|
// Get the form API either manually or from context
|
||||||
|
const { formApi, parentFieldName } = useFormContext()
|
||||||
|
|
||||||
|
const fieldApi = (() => {
|
||||||
|
const name = (
|
||||||
|
typeof opts.index === 'number'
|
||||||
|
? [parentFieldName, opts.index, opts.name]
|
||||||
|
: [parentFieldName, opts.name]
|
||||||
|
)
|
||||||
|
.filter((d) => d !== undefined)
|
||||||
|
.join('.')
|
||||||
|
|
||||||
|
const api = new FieldApi({ ...opts, form: formApi, name: name as never })
|
||||||
|
|
||||||
|
api.Field = Field as never
|
||||||
|
|
||||||
|
return api
|
||||||
|
})()
|
||||||
|
|
||||||
|
const fieldState = useStore(fieldApi.store, (state) => state)
|
||||||
|
|
||||||
|
let cleanup!: () => void
|
||||||
|
onMounted(() => {
|
||||||
|
cleanup = fieldApi.mount()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => opts,
|
||||||
|
() => {
|
||||||
|
// Keep options up to date as they are rendered
|
||||||
|
fieldApi.update({ ...opts, form: formApi } as never)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return { api: fieldApi, state: fieldState } as never
|
||||||
|
}
|
||||||
|
|
||||||
|
// export type FieldValue<TFormData, TField> = TFormData extends any[]
|
||||||
|
// ? TField extends `[${infer TIndex extends number | 'i'}].${infer TRest}`
|
||||||
|
// ? DeepValue<TFormData[TIndex extends 'i' ? number : TIndex], TRest>
|
||||||
|
// : TField extends `[${infer TIndex extends number | 'i'}]`
|
||||||
|
// ? TFormData[TIndex extends 'i' ? number : TIndex]
|
||||||
|
// : never
|
||||||
|
// : TField extends `${infer TPrefix}[${infer TIndex extends
|
||||||
|
// | number
|
||||||
|
// | 'i'}].${infer TRest}`
|
||||||
|
// ? DeepValue<
|
||||||
|
// DeepValue<TFormData, TPrefix>[TIndex extends 'i' ? number : TIndex],
|
||||||
|
// TRest
|
||||||
|
// >
|
||||||
|
// : TField extends `${infer TPrefix}[${infer TIndex extends number | 'i'}]`
|
||||||
|
// ? DeepValue<TFormData, TPrefix>[TIndex extends 'i' ? number : TIndex]
|
||||||
|
// : DeepValue<TFormData, TField>
|
||||||
|
|
||||||
|
export type FieldValue<TFormData, TField> = TFormData extends any[]
|
||||||
|
? unknown extends TField
|
||||||
|
? TFormData[number]
|
||||||
|
: DeepValue<TFormData[number], TField>
|
||||||
|
: DeepValue<TFormData, TField>
|
||||||
|
|
||||||
|
// type Test1 = FieldValue<{ foo: { bar: string }[] }, 'foo'>
|
||||||
|
// // ^?
|
||||||
|
// type Test2 = FieldValue<{ foo: { bar: string }[] }, 'foo[i]'>
|
||||||
|
// // ^?
|
||||||
|
// type Test3 = FieldValue<{ foo: { bar: string }[] }, 'foo[2].bar'>
|
||||||
|
// // ^?
|
||||||
|
|
||||||
|
export type FieldComponent<TParentData, TFormData> = <TField>(
|
||||||
|
fieldOptions: {
|
||||||
|
children?: (
|
||||||
|
fieldApi: FieldApi<FieldValue<TParentData, TField>, TFormData>,
|
||||||
|
) => any
|
||||||
|
} & Omit<
|
||||||
|
UseFieldOptions<FieldValue<TParentData, TField>, TFormData>,
|
||||||
|
'name' | 'index'
|
||||||
|
> &
|
||||||
|
(TParentData extends any[]
|
||||||
|
? {
|
||||||
|
name?: TField extends undefined ? TField : DeepKeys<TParentData>
|
||||||
|
index: number
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
name: TField extends undefined ? TField : DeepKeys<TParentData>
|
||||||
|
index?: never
|
||||||
|
}),
|
||||||
|
context: SetupContext,
|
||||||
|
) => any
|
||||||
|
|
||||||
|
export const Field = defineComponent(
|
||||||
|
<TData, TFormData>(
|
||||||
|
fieldOptions: UseFieldOptions<TData, TFormData>,
|
||||||
|
context: SetupContext,
|
||||||
|
) => {
|
||||||
|
const fieldApi = useField({ ...fieldOptions, ...context.attrs })
|
||||||
|
|
||||||
|
provideFormContext({
|
||||||
|
formApi: fieldApi.api.form,
|
||||||
|
parentFieldName: fieldApi.api.name,
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
return () => context.slots.default!(fieldApi.api, fieldApi.state.value)
|
||||||
|
},
|
||||||
|
{ name: 'Field', inheritAttrs: false },
|
||||||
|
)
|
||||||
63
packages/vue-form/src/useForm.tsx
Normal file
63
packages/vue-form/src/useForm.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { FormApi, type FormState, type FormOptions } from '@tanstack/form-core'
|
||||||
|
import { useStore } from '@tanstack/vue-store'
|
||||||
|
import { type UseField, type FieldComponent, Field, useField } from './useField'
|
||||||
|
import { provideFormContext } from './formContext'
|
||||||
|
import { defineComponent } from 'vue-demi'
|
||||||
|
import type { NoInfer } from './types'
|
||||||
|
|
||||||
|
declare module '@tanstack/form-core' {
|
||||||
|
// eslint-disable-next-line no-shadow
|
||||||
|
interface FormApi<TFormData> {
|
||||||
|
Provider: (props: Record<string, any> & {}) => any
|
||||||
|
provideFormContext: () => void
|
||||||
|
Field: FieldComponent<TFormData, TFormData>
|
||||||
|
useField: UseField<TFormData>
|
||||||
|
useStore: <TSelected = NoInfer<FormState<TFormData>>>(
|
||||||
|
selector?: (state: NoInfer<FormState<TFormData>>) => TSelected,
|
||||||
|
) => TSelected
|
||||||
|
Subscribe: <TSelected = NoInfer<FormState<TFormData>>>(props: {
|
||||||
|
selector?: (state: NoInfer<FormState<TFormData>>) => TSelected
|
||||||
|
}) => any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useForm<TData>(opts?: FormOptions<TData>): FormApi<TData> {
|
||||||
|
const formApi = (() => {
|
||||||
|
const api = new FormApi<TData>(opts)
|
||||||
|
|
||||||
|
api.Provider = defineComponent(
|
||||||
|
(_, context) => {
|
||||||
|
provideFormContext({ formApi })
|
||||||
|
return () => context.slots.default!()
|
||||||
|
},
|
||||||
|
{ name: 'Provider' },
|
||||||
|
)
|
||||||
|
api.provideFormContext = () => {
|
||||||
|
provideFormContext({ formApi })
|
||||||
|
}
|
||||||
|
api.Field = Field as never
|
||||||
|
api.useField = useField as never
|
||||||
|
api.useStore = (selector) => {
|
||||||
|
return useStore(api.store as never, selector as never) as never
|
||||||
|
}
|
||||||
|
api.Subscribe = defineComponent(
|
||||||
|
(props, context) => {
|
||||||
|
const allProps = { ...props, ...context.attrs }
|
||||||
|
const selector = allProps.selector ?? ((state) => state)
|
||||||
|
const data = useStore(api.store as never, selector as never)
|
||||||
|
return () => context.slots.default!(data.value)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Subscribe',
|
||||||
|
inheritAttrs: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return api
|
||||||
|
})()
|
||||||
|
|
||||||
|
// formApi.useStore((state) => state.isSubmitting)
|
||||||
|
formApi.update(opts)
|
||||||
|
|
||||||
|
return formApi as never
|
||||||
|
}
|
||||||
8
packages/vue-form/test-setup.ts
Normal file
8
packages/vue-form/test-setup.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import Vue from 'vue2'
|
||||||
|
Vue.config.productionTip = false
|
||||||
|
Vue.config.devtools = false
|
||||||
|
|
||||||
|
// Hide annoying console warnings for Vue2
|
||||||
|
import Vue27 from 'vue2.7'
|
||||||
|
Vue27.config.productionTip = false
|
||||||
|
Vue27.config.devtools = false
|
||||||
10
packages/vue-form/tsconfig.json
Normal file
10
packages/vue-form/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["vitest/globals", "vue/jsx"],
|
||||||
|
"outDir": "./build/lib",
|
||||||
|
"jsx": "preserve",
|
||||||
|
"jsxImportSource": "vue"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
17
packages/vue-form/vitest.config.ts
Normal file
17
packages/vue-form/vitest.config.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
name: 'vue-query',
|
||||||
|
dir: './src',
|
||||||
|
watch: false,
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ['test-setup.ts'],
|
||||||
|
coverage: { provider: 'istanbul' },
|
||||||
|
},
|
||||||
|
esbuild: {
|
||||||
|
jsxFactory: 'h',
|
||||||
|
jsxFragment: 'Fragment',
|
||||||
|
},
|
||||||
|
})
|
||||||
2911
pnpm-lock.yaml
generated
2911
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,9 @@ import commonJS from '@rollup/plugin-commonjs'
|
|||||||
import externals from 'rollup-plugin-node-externals'
|
import externals from 'rollup-plugin-node-externals'
|
||||||
import preserveDirectives from 'rollup-plugin-preserve-directives'
|
import preserveDirectives from 'rollup-plugin-preserve-directives'
|
||||||
import { rootDir } from './config.js'
|
import { rootDir } from './config.js'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import vueJsx from '@vitejs/plugin-vue-jsx'
|
||||||
|
import esbuild from 'rollup-plugin-esbuild'
|
||||||
|
|
||||||
/** @param {'development' | 'production'} type */
|
/** @param {'development' | 'production'} type */
|
||||||
const forceEnvPlugin = (type) =>
|
const forceEnvPlugin = (type) =>
|
||||||
@@ -106,7 +109,26 @@ function modernConfig(opts) {
|
|||||||
input: [opts.entryFile],
|
input: [opts.entryFile],
|
||||||
output: forceBundle ? bundleOutput : normalOutput,
|
output: forceBundle ? bundleOutput : normalOutput,
|
||||||
plugins: [
|
plugins: [
|
||||||
|
vue({
|
||||||
|
isProduction: true,
|
||||||
|
}),
|
||||||
|
vueJsx({
|
||||||
|
exclude: [
|
||||||
|
'./packages/solid-form/**',
|
||||||
|
'./packages/svelte-form/**',
|
||||||
|
'./packages/react-form/**',
|
||||||
|
],
|
||||||
|
}),
|
||||||
commonJS(),
|
commonJS(),
|
||||||
|
esbuild({
|
||||||
|
exclude: [],
|
||||||
|
loaders: {
|
||||||
|
'.vue': 'ts',
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
babelPlugin('modern'),
|
babelPlugin('modern'),
|
||||||
nodeResolve({ extensions: ['.ts', '.tsx'] }),
|
nodeResolve({ extensions: ['.ts', '.tsx'] }),
|
||||||
forceDevEnv ? forceEnvPlugin('development') : undefined,
|
forceDevEnv ? forceEnvPlugin('development') : undefined,
|
||||||
@@ -181,7 +203,26 @@ function legacyConfig(opts) {
|
|||||||
input: [opts.entryFile],
|
input: [opts.entryFile],
|
||||||
output: forceBundle ? bundleOutput : normalOutput,
|
output: forceBundle ? bundleOutput : normalOutput,
|
||||||
plugins: [
|
plugins: [
|
||||||
|
vue({
|
||||||
|
isProduction: true,
|
||||||
|
}),
|
||||||
|
vueJsx({
|
||||||
|
exclude: [
|
||||||
|
'./packages/solid-form/**',
|
||||||
|
'./packages/svelte-form/**',
|
||||||
|
'./packages/react-form/**',
|
||||||
|
],
|
||||||
|
}),
|
||||||
commonJS(),
|
commonJS(),
|
||||||
|
esbuild({
|
||||||
|
exclude: [],
|
||||||
|
loaders: {
|
||||||
|
'.vue': 'ts',
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
babelPlugin('legacy'),
|
babelPlugin('legacy'),
|
||||||
nodeResolve({ extensions: ['.ts', '.tsx'] }),
|
nodeResolve({ extensions: ['.ts', '.tsx'] }),
|
||||||
forceDevEnv ? forceEnvPlugin('development') : undefined,
|
forceDevEnv ? forceEnvPlugin('development') : undefined,
|
||||||
|
|||||||
Reference in New Issue
Block a user