diff --git a/.changeset/form-schema-244.md b/.changeset/form-schema-244.md new file mode 100644 index 000000000..d856301b6 --- /dev/null +++ b/.changeset/form-schema-244.md @@ -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.` onto any input component. diff --git a/knip.json b/knip.json index 55b372647..8ef6939c0 100644 --- a/knip.json +++ b/knip.json @@ -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": { diff --git a/packages/0/src/composables/createFormSchema/index.test.ts b/packages/0/src/composables/createFormSchema/index.test.ts new file mode 100644 index 000000000..7cf0c2990 --- /dev/null +++ b/packages/0/src/composables/createFormSchema/index.test.ts @@ -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() + }) + }) +}) diff --git a/packages/0/src/composables/createFormSchema/index.ts b/packages/0/src/composables/createFormSchema/index.ts new file mode 100644 index 000000000..ce4662ed5 --- /dev/null +++ b/packages/0/src/composables/createFormSchema/index.ts @@ -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 `` + * 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: + * // + * + * // 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 { + /** 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 { + /** 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 +} + +/** + * The object returned by `createFormSchema`. + * + * @template S The schema definition type. + */ +export interface FormSchema { + /** 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 ? T : unknown> } + /** + * Per-field bindings ready to spread onto input components. + * + * @example `` + */ + fields: { [K in keyof S]: FieldBindings ? T : unknown> } + /** Tri-state aggregate validity: `null` (not validated), `true`, or `false`. */ + isValid: ComputedRef + /** Whether any field is currently running async validation. */ + isValidating: ComputedRef + /** + * Trigger validation on all fields. + * @returns `true` if every field is valid. + */ + submit: () => Promise + /** 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 (schema: S): FormSchema { + const form = createForm() + + type Values = { [K in keyof S]: ShallowRef ? T : unknown> } + type Fields = { [K in keyof S]: FieldBindings ? T : unknown> } + + const values = {} as Values + const fields = {} as Fields + const defaults: Record = {} + + for (const key in schema) { + const def = schema[key] + const initial = def.default ?? null + defaults[key] = initial + + const ref = shallowRef(initial) as ShallowRef + ;(values as Record>)[key] = ref + + const validation = createValidation({ + value: ref, + rules: def.rules ?? [], + }) + + form.register({ id: key, value: validation }) + + ;(fields as Record)[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>)[key].value = defaults[key] + } + form.reset() + } + + return { + form, + values, + fields, + isValid: form.isValid, + isValidating: form.isValidating, + submit: form.submit, + reset, + } +} diff --git a/packages/0/src/composables/index.ts b/packages/0/src/composables/index.ts index 7d7a7509e..24f15ddb3 100644 --- a/packages/0/src/composables/index.ts +++ b/packages/0/src/composables/index.ts @@ -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' diff --git a/packages/0/src/surface.test.ts b/packages/0/src/surface.test.ts index b9ba28039..a757168b9 100644 --- a/packages/0/src/surface.test.ts +++ b/packages/0/src/surface.test.ts @@ -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 = [