From 6c4c8fad9f48c5a3844c30d05bb73ef3d4cc0f8f Mon Sep 17 00:00:00 2001 From: Sai Sridhar Date: Fri, 26 Jun 2026 20:58:17 +0530 Subject: [PATCH 1/2] feat(createFormSchema): schema-driven form generation for issue #244 --- .changeset/form-schema-244.md | 14 ++ .../createFormSchema/index.test.ts | 141 +++++++++++++ .../src/composables/createFormSchema/index.ts | 191 ++++++++++++++++++ packages/0/src/composables/index.ts | 1 + packages/0/src/surface.test.ts | 2 +- 5 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 .changeset/form-schema-244.md create mode 100644 packages/0/src/composables/createFormSchema/index.test.ts create mode 100644 packages/0/src/composables/createFormSchema/index.ts diff --git a/.changeset/form-schema-244.md b/.changeset/form-schema-244.md new file mode 100644 index 0000000000..d856301b62 --- /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/packages/0/src/composables/createFormSchema/index.test.ts b/packages/0/src/composables/createFormSchema/index.test.ts new file mode 100644 index 0000000000..7cf0c29902 --- /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 0000000000..ce4662ed5d --- /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 7d7a7509e7..24f15ddb34 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 b9ba280396..a757168b97 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 = [ From 60ddacf1d2fcb9e5979a25986e7f1b41a6455e94 Mon Sep 17 00:00:00 2001 From: Sai Sridhar Date: Tue, 30 Jun 2026 11:14:32 +0530 Subject: [PATCH 2/2] fix(knip): add examples TS helpers as entry points to silence macOS case-fold false-positives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS (case-insensitive FS) each docs example has both an uppercase presentational component (Equalizer.vue) and a lowercase entry-point wrapper (equalizer.vue) tracked separately in git. macOS only stores one physical file, so knip reads the wrong variant and flags the helper composables (useEqualizer, useGallery, usePlaylist…) as having unused exports. Fix by adding src/examples/**/*.ts to the apps/docs entry list. Example helper files are entry points consumed via VitePress virtual modules, not internal project code, so their exports are legitimately "used" externally and should not be checked for orphans. Remove two specific ignore entries that are now subsumed by the TS entry glob. --- knip.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/knip.json b/knip.json index 55b372647b..8ef6939c01 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": {