Skip to content
Draft
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
14 changes: 14 additions & 0 deletions .changeset/form-schema-244.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@vuetify/v0": minor
---

feat(createFormSchema): schema-driven form generation (#244)

Adds `createFormSchema` — a thin composable over `createForm` and
`createValidation` that accepts a typed field-definition object and returns
reactive value refs, per-field bindings (modelValue / onUpdate:modelValue /
errorMessages), and form-level `submit`, `reset`, `isValid`, and
`isValidating` helpers.

The schema is UI-agnostic: consumers decide how each field is rendered and can
spread `schema.fields.<name>` onto any input component.
5 changes: 2 additions & 3 deletions knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,12 @@
"src/pages/**/*.{vue,md}",
"build/**/*.ts",
"vite.config.*",
"src/examples/**/*.vue"
"src/examples/**/*.vue",
"src/examples/**/*.ts"
],
"ignore": [
"src/typed-router.d.ts",
"src/examples/guide/building-frameworks/my-ui/src/**/*.ts",
"src/examples/guide/building-frameworks/my-ui/vite.config.ts",
"src/examples/composables/create-trinity/toasts.ts",
"src/skillz/tours/**/index.ts"
],
"paths": {
Expand Down
141 changes: 141 additions & 0 deletions packages/0/src/composables/createFormSchema/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { describe, expect, it } from 'vitest'

import { createFormSchema } from './index'

describe('createFormSchema', () => {
describe('initial state', () => {
it('should apply default values to value refs', () => {
const schema = createFormSchema({
name: { default: 'Alice' },
age: { default: 30 },
})

expect(schema.values.name.value).toBe('Alice')
expect(schema.values.age.value).toBe(30)
})

it('should default to null when no default is provided', () => {
const schema = createFormSchema({ email: {} })

expect(schema.values.email.value).toBeNull()
})

it('should expose isValid as null before any validation', () => {
const schema = createFormSchema({ x: { rules: [v => !!v || 'Required'] } })

expect(schema.isValid.value).toBeNull()
})
})

describe('fields bindings', () => {
it('should have modelValue matching the current ref value', () => {
const schema = createFormSchema({ name: { default: 'Bob' } })

expect(schema.fields.name.modelValue).toBe('Bob')
})

it('should update the ref when onUpdate:modelValue is called', () => {
const schema = createFormSchema({ name: { default: '' } })

schema.fields.name['onUpdate:modelValue']('Charlie')

expect(schema.values.name.value).toBe('Charlie')
expect(schema.fields.name.modelValue).toBe('Charlie')
})

it('should expose an errorMessages ref per field', () => {
const schema = createFormSchema({ email: { rules: [v => !!v || 'Required'] } })

expect(schema.fields.email.errorMessages.value).toEqual([])
})
})

describe('submit and validation', () => {
it('should return true when all fields pass validation', async () => {
const schema = createFormSchema({
name: { default: 'Alice', rules: [v => !!v || 'Required'] },
})

const ok = await schema.submit()

expect(ok).toBe(true)
expect(schema.isValid.value).toBe(true)
})

it('should return false when a field fails validation', async () => {
const schema = createFormSchema({
name: { default: '', rules: [v => !!v || 'Required'] },
})

const ok = await schema.submit()

expect(ok).toBe(false)
expect(schema.isValid.value).toBe(false)
})

it('should populate errorMessages on the failing field', async () => {
const schema = createFormSchema({
email: { default: '', rules: [v => !!v || 'Email is required'] },
})

await schema.submit()

expect(schema.fields.email.errorMessages.value).toContain('Email is required')
})

it('should handle multiple rules per field', async () => {
const schema = createFormSchema({
age: {
default: 5,
rules: [
v => Number(v) >= 0 || 'Must be non-negative',
v => Number(v) >= 18 || 'Must be 18 or older',
],
},
})

await schema.submit()

expect(schema.fields.age.errorMessages.value).toContain('Must be 18 or older')
})

it('should validate multiple fields independently', async () => {
const schema = createFormSchema({
username: { default: 'alice', rules: [v => !!v || 'Required'] },
email: { default: '', rules: [v => !!v || 'Required'] },
})

const ok = await schema.submit()

expect(ok).toBe(false)
expect(schema.fields.username.errorMessages.value).toEqual([])
expect(schema.fields.email.errorMessages.value).toContain('Required')
})
})

describe('reset', () => {
it('should restore fields to their defaults after mutation', async () => {
const schema = createFormSchema({
name: { default: 'Alice', rules: [v => !!v || 'Required'] },
})

schema.fields.name['onUpdate:modelValue']('Bob')
await schema.submit()

schema.reset()

expect(schema.values.name.value).toBe('Alice')
expect(schema.fields.name.errorMessages.value).toEqual([])
expect(schema.isValid.value).toBeNull()
})

it('should restore null when field had no default', () => {
const schema = createFormSchema({ x: {} })

schema.fields.x['onUpdate:modelValue']('something')
schema.reset()

expect(schema.values.x.value).toBeNull()
})
})
})
191 changes: 191 additions & 0 deletions packages/0/src/composables/createFormSchema/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
/**
* @module createFormSchema
*
* @see https://0.vuetifyjs.com/composables/forms/create-form-schema
*
* @remarks
* Schema-driven form generation built on top of `createForm` and
* `createValidation`. Accepts a typed field-definition object and returns
* reactive value refs, per-field bindings ready to spread onto any input
* component, and form-level submit / reset helpers.
*
* The schema is intentionally UI-agnostic — consumers decide how each field
* is rendered. This makes it usable with any component library, plain `<input>`
* elements, or renderless headless primitives.
*
* @example
* ```ts
* import { createFormSchema } from '@vuetify/v0'
*
* const schema = createFormSchema({
* username: {
* default: '',
* rules: ['required', v => String(v).length >= 3 || 'Min 3 characters'],
* },
* email: {
* default: '',
* rules: ['required', 'email'],
* },
* })
*
* // Spread onto any input component:
* // <TextField v-bind="schema.fields.username" />
*
* // Submit and get a boolean result:
* const ok = await schema.submit()
* if (ok) console.log(schema.values.username.value)
*
* // Reset all fields:
* schema.reset()
* ```
*/

// Composables
import { createForm } from '#v0/composables/createForm'
import { createValidation } from '#v0/composables/createValidation'

// Utilities
import { shallowRef } from 'vue'

// Types
import type { FormContext } from '#v0/composables/createForm'
import type { RuleInput } from '#v0/composables/useRules'
import type { ComputedRef, ShallowRef } from 'vue'

/**
* Definition for a single field in a schema.
*
* @template T The value type of the field.
*/
export interface FieldDefinition<T = unknown> {
/** Initial/default value. Defaults to `null`. */
default?: T
/** Validation rules. Accepts aliases ('required'), functions, or standard schemas (Zod, Valibot). */
rules?: RuleInput[]
}

/**
* Map of field definitions keyed by field name.
*/
export interface FormSchemaDefinition {
[key: string]: FieldDefinition
}

/**
* Reactive bindings for a single field, ready to spread onto an input component.
*
* @template T The value type of the field.
*/
export interface FieldBindings<T = unknown> {
/** Current field value — use as `:model-value`. */
'modelValue': T
/** Value setter — use as `@update:model-value`. */
'onUpdate:modelValue': (value: T) => void
/** Validation error messages. Empty when valid or not yet validated. */
'errorMessages': ShallowRef<string[]>
}

/**
* The object returned by `createFormSchema`.
*
* @template S The schema definition type.
*/
export interface FormSchema<S extends FormSchemaDefinition> {
/** The underlying `FormContext` for advanced control. */
form: FormContext
/**
* Reactive field value refs keyed by field name.
* Mutate `.value` to change the field value programmatically.
*/
values: { [K in keyof S]: ShallowRef<S[K] extends FieldDefinition<infer T> ? T : unknown> }
/**
* Per-field bindings ready to spread onto input components.
*
* @example `<TextField v-bind="schema.fields.email" />`
*/
fields: { [K in keyof S]: FieldBindings<S[K] extends FieldDefinition<infer T> ? T : unknown> }
/** Tri-state aggregate validity: `null` (not validated), `true`, or `false`. */
isValid: ComputedRef<boolean | null>
/** Whether any field is currently running async validation. */
isValidating: ComputedRef<boolean>
/**
* Trigger validation on all fields.
* @returns `true` if every field is valid.
*/
submit: () => Promise<boolean>
/** Reset all fields to their initial values and clear all error states. */
reset: () => void
}

/**
* Creates a schema-driven form with typed reactive field bindings.
*
* @param schema Field definitions keyed by name.
* @returns A `FormSchema` object with reactive values, per-field bindings,
* and form-level submit / reset / validity state.
*
* @see https://0.vuetifyjs.com/composables/forms/create-form-schema
*
* @example
* ```ts
* const schema = createFormSchema({
* name: { default: '', rules: ['required'] },
* age: { default: null, rules: ['required', v => Number(v) >= 18 || 'Must be 18+'] },
* })
*
* const isValid = await schema.submit()
* ```
*/
export function createFormSchema<S extends FormSchemaDefinition> (schema: S): FormSchema<S> {
const form = createForm()

type Values = { [K in keyof S]: ShallowRef<S[K] extends FieldDefinition<infer T> ? T : unknown> }
type Fields = { [K in keyof S]: FieldBindings<S[K] extends FieldDefinition<infer T> ? T : unknown> }

const values = {} as Values
const fields = {} as Fields
const defaults: Record<string, unknown> = {}

for (const key in schema) {
const def = schema[key]
const initial = def.default ?? null
defaults[key] = initial

const ref = shallowRef(initial) as ShallowRef<unknown>
;(values as Record<string, ShallowRef<unknown>>)[key] = ref

const validation = createValidation({
value: ref,
rules: def.rules ?? [],
})

form.register({ id: key, value: validation })

;(fields as Record<string, FieldBindings>)[key] = {
'errorMessages': validation.errors,
get 'modelValue' () {
return ref.value
},
'onUpdate:modelValue': (v: unknown) => {
ref.value = v
},
}
}

function reset (): void {
for (const key in schema) {
;(values as Record<string, ShallowRef<unknown>>)[key].value = defaults[key]
}
form.reset()
}

return {
form,
values,
fields,
isValid: form.isValid,
isValidating: form.isValidating,
submit: form.submit,
reset,
}
}
1 change: 1 addition & 0 deletions packages/0/src/composables/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export * from './createDataGrid'
export * from './createDataTable'
export * from './createFilter'
export * from './createForm'
export * from './createFormSchema'
export * from './createGroup'
export * from './createInput'
export * from './createKanban'
Expand Down
2 changes: 1 addition & 1 deletion packages/0/src/surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import type {
*/

const COMPOSABLES = [
'ClientComboboxAdapter', 'ClientDataTableAdapter', 'ComboboxAdapter', 'ConsolaLoggerAdapter', 'DataTableAdapter', 'DateAdapter', 'DragDropAdapter', 'FeaturesAdapter', 'KeyboardAdapter', 'LocaleAdapter', 'LoggerAdapter', 'MemoryStorageAdapter', 'PermissionsAdapter', 'PinoLoggerAdapter', 'PointerAdapter', 'ReducedMotionAdapter', 'RtlAdapter', 'ServerComboboxAdapter', 'ServerDataTableAdapter', 'ServerGridAdapter', 'StorageAdapter', 'ThemeAdapter', 'V0LocaleAdapter', 'V0LoggerAdapter', 'V0ReducedMotionAdapter', 'V0RtlAdapter', 'V0StyleSheetThemeAdapter', 'V0UnheadThemeAdapter', 'VirtualDataTableAdapter', 'computeDepth', 'createBreadcrumbs', 'createBreadcrumbsContext', 'createBreakpoints', 'createBreakpointsContext', 'createBreakpointsPlugin', 'createCombobox', 'createComboboxContext', 'createContext', 'createDataGrid', 'createDataGridContext', 'createDataTable', 'createDataTableContext', 'createDate', 'createDateContext', 'createDatePlugin', 'createFallbackHydration', 'createFeatures', 'createFeaturesContext', 'createFeaturesPlugin', 'createFilter', 'createFilterContext', 'createForm', 'createFormContext', 'createGroup', 'createGroupContext', 'createHydration', 'createHydrationContext', 'createHydrationPlugin', 'createInput', 'createKanban', 'createLocale', 'createLocaleContext', 'createLocaleFallback', 'createLocalePlugin', 'createLogger', 'createLoggerContext', 'createLoggerPlugin', 'createModel', 'createNested', 'createNestedContext', 'createNotifications', 'createNotificationsContext', 'createNotificationsPlugin', 'createNumberField', 'createNumeric', 'createOtp', 'createOverflow', 'createOverflowContext', 'createPagination', 'createPaginationContext', 'createPermissions', 'createPermissionsContext', 'createPermissionsPlugin', 'createPlugin', 'createPluginContext', 'createProgress', 'createProgressContext', 'createQueue', 'createQueueContext', 'createRating', 'createRatingContext', 'createReducedMotion', 'createReducedMotionContext', 'createReducedMotionPlugin', 'createRegistry', 'createRegistryContext', 'createRtl', 'createRtlContext', 'createRtlFallback', 'createRtlPlugin', 'createRules', 'createRulesContext', 'createRulesFallback', 'createRulesPlugin', 'createSelection', 'createSelectionContext', 'createSingle', 'createSingleContext', 'createSlider', 'createSortable', 'createStack', 'createStackContext', 'createStackPlugin', 'createStep', 'createStepContext', 'createStorage', 'createStorageContext', 'createStoragePlugin', 'createTheme', 'createThemeContext', 'createThemePlugin', 'createTimeline', 'createTimelineContext', 'createTokens', 'createTokensContext', 'createTooltipContext', 'createTooltipFallback', 'createTooltipPlugin', 'createTrinity', 'createValidation', 'createVirtual', 'createVirtualContext', 'extractLeaves', 'flatten', 'isStandardSchema', 'provideContext', 'resolveHeaders', 'toArray', 'toElement', 'toHighlight', 'toReactive', 'useBreadcrumbs', 'useBreakpoints', 'useClickOutside', 'useCombobox', 'useContext', 'useDataGrid', 'useDataTable', 'useDate', 'useDelay', 'useDocumentEventListener', 'useDragDrop', 'useElementIntersection', 'useElementSize', 'useEventListener', 'useFeatures', 'useFilter', 'useForm', 'useGroup', 'useHotkey', 'useHydration', 'useImage', 'useIntersectionObserver', 'useLazy', 'useLocale', 'useLogger', 'useMediaQuery', 'useMutationObserver', 'useNested', 'useNotifications', 'useOverflow', 'usePagination', 'usePermissions', 'usePopover', 'usePrefersContrast', 'usePrefersDark', 'usePrefersReducedMotion', 'usePresence', 'useProgress', 'useProxyModel', 'useProxyRegistry', 'useQueue', 'useRaf', 'useRating', 'useReducedMotion', 'useRegistry', 'useResizeObserver', 'useRovingFocus', 'useRtl', 'useRules', 'useSelection', 'useSingle', 'useStack', 'useStep', 'useStorage', 'useTheme', 'useTimeline', 'useTimer', 'useToggleScope', 'useTokens', 'useTooltip', 'useVirtual', 'useVirtualFocus', 'useWindowEventListener',
'ClientComboboxAdapter', 'ClientDataTableAdapter', 'ComboboxAdapter', 'ConsolaLoggerAdapter', 'DataTableAdapter', 'DateAdapter', 'DragDropAdapter', 'FeaturesAdapter', 'KeyboardAdapter', 'LocaleAdapter', 'LoggerAdapter', 'MemoryStorageAdapter', 'PermissionsAdapter', 'PinoLoggerAdapter', 'PointerAdapter', 'ReducedMotionAdapter', 'RtlAdapter', 'ServerComboboxAdapter', 'ServerDataTableAdapter', 'ServerGridAdapter', 'StorageAdapter', 'ThemeAdapter', 'V0LocaleAdapter', 'V0LoggerAdapter', 'V0ReducedMotionAdapter', 'V0RtlAdapter', 'V0StyleSheetThemeAdapter', 'V0UnheadThemeAdapter', 'VirtualDataTableAdapter', 'computeDepth', 'createBreadcrumbs', 'createBreadcrumbsContext', 'createBreakpoints', 'createBreakpointsContext', 'createBreakpointsPlugin', 'createCombobox', 'createComboboxContext', 'createContext', 'createDataGrid', 'createDataGridContext', 'createDataTable', 'createDataTableContext', 'createDate', 'createDateContext', 'createDatePlugin', 'createFallbackHydration', 'createFeatures', 'createFeaturesContext', 'createFeaturesPlugin', 'createFilter', 'createFilterContext', 'createForm', 'createFormContext', 'createFormSchema', 'createGroup', 'createGroupContext', 'createHydration', 'createHydrationContext', 'createHydrationPlugin', 'createInput', 'createKanban', 'createLocale', 'createLocaleContext', 'createLocaleFallback', 'createLocalePlugin', 'createLogger', 'createLoggerContext', 'createLoggerPlugin', 'createModel', 'createNested', 'createNestedContext', 'createNotifications', 'createNotificationsContext', 'createNotificationsPlugin', 'createNumberField', 'createNumeric', 'createOtp', 'createOverflow', 'createOverflowContext', 'createPagination', 'createPaginationContext', 'createPermissions', 'createPermissionsContext', 'createPermissionsPlugin', 'createPlugin', 'createPluginContext', 'createProgress', 'createProgressContext', 'createQueue', 'createQueueContext', 'createRating', 'createRatingContext', 'createReducedMotion', 'createReducedMotionContext', 'createReducedMotionPlugin', 'createRegistry', 'createRegistryContext', 'createRtl', 'createRtlContext', 'createRtlFallback', 'createRtlPlugin', 'createRules', 'createRulesContext', 'createRulesFallback', 'createRulesPlugin', 'createSelection', 'createSelectionContext', 'createSingle', 'createSingleContext', 'createSlider', 'createSortable', 'createStack', 'createStackContext', 'createStackPlugin', 'createStep', 'createStepContext', 'createStorage', 'createStorageContext', 'createStoragePlugin', 'createTheme', 'createThemeContext', 'createThemePlugin', 'createTimeline', 'createTimelineContext', 'createTokens', 'createTokensContext', 'createTooltipContext', 'createTooltipFallback', 'createTooltipPlugin', 'createTrinity', 'createValidation', 'createVirtual', 'createVirtualContext', 'extractLeaves', 'flatten', 'isStandardSchema', 'provideContext', 'resolveHeaders', 'toArray', 'toElement', 'toHighlight', 'toReactive', 'useBreadcrumbs', 'useBreakpoints', 'useClickOutside', 'useCombobox', 'useContext', 'useDataGrid', 'useDataTable', 'useDate', 'useDelay', 'useDocumentEventListener', 'useDragDrop', 'useElementIntersection', 'useElementSize', 'useEventListener', 'useFeatures', 'useFilter', 'useForm', 'useGroup', 'useHotkey', 'useHydration', 'useImage', 'useIntersectionObserver', 'useLazy', 'useLocale', 'useLogger', 'useMediaQuery', 'useMutationObserver', 'useNested', 'useNotifications', 'useOverflow', 'usePagination', 'usePermissions', 'usePopover', 'usePrefersContrast', 'usePrefersDark', 'usePrefersReducedMotion', 'usePresence', 'useProgress', 'useProxyModel', 'useProxyRegistry', 'useQueue', 'useRaf', 'useRating', 'useReducedMotion', 'useRegistry', 'useResizeObserver', 'useRovingFocus', 'useRtl', 'useRules', 'useSelection', 'useSingle', 'useStack', 'useStep', 'useStorage', 'useTheme', 'useTimeline', 'useTimer', 'useToggleScope', 'useTokens', 'useTooltip', 'useVirtual', 'useVirtualFocus', 'useWindowEventListener',
]

const COMPONENTS = [
Expand Down
Loading