Add Shadcn Form components with vee-validate and zod integration

- Install Shadcn Form components (FormControl, FormDescription, FormItem, FormLabel, FormMessage)
- Add vee-validate 4.15.1 for form validation
- Add zod 3.25.76 for schema validation
- Add @vee-validate/zod 4.15.1 for integration
- Update reka-ui to 2.5.0
- Prepare foundation for proper form handling with type-safe validation
- Enables proper checkbox array handling and form accessibility

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
padreug 2025-09-08 15:59:08 +02:00
parent a373fa714d
commit b0a2d1a6df
10 changed files with 324 additions and 9 deletions

View file

@ -0,0 +1,17 @@
<script lang="ts" setup>
import { Slot } from "reka-ui"
import { useFormField } from "./useFormField"
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
</script>
<template>
<Slot
:id="formItemId"
data-slot="form-control"
:aria-describedby="!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`"
:aria-invalid="!!error"
>
<slot />
</Slot>
</template>

View file

@ -0,0 +1,21 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { useFormField } from "./useFormField"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
const { formDescriptionId } = useFormField()
</script>
<template>
<p
:id="formDescriptionId"
data-slot="form-description"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</p>
</template>

View file

@ -0,0 +1,23 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { useId } from "reka-ui"
import { provide } from "vue"
import { cn } from "@/lib/utils"
import { FORM_ITEM_INJECTION_KEY } from "./injectionKeys"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
const id = useId()
provide(FORM_ITEM_INJECTION_KEY, id)
</script>
<template>
<div
data-slot="form-item"
:class="cn('grid gap-2', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,25 @@
<script lang="ts" setup>
import type { LabelProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { Label } from '@/components/ui/label'
import { useFormField } from "./useFormField"
const props = defineProps<LabelProps & { class?: HTMLAttributes["class"] }>()
const { error, formItemId } = useFormField()
</script>
<template>
<Label
data-slot="form-label"
:data-error="!!error"
:class="cn(
'data-[error=true]:text-destructive',
props.class,
)"
:for="formItemId"
>
<slot />
</Label>
</template>

View file

@ -0,0 +1,23 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { ErrorMessage } from "vee-validate"
import { toValue } from "vue"
import { cn } from "@/lib/utils"
import { useFormField } from "./useFormField"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
const { name, formMessageId } = useFormField()
</script>
<template>
<ErrorMessage
:id="formMessageId"
data-slot="form-message"
as="p"
:name="toValue(name)"
:class="cn('text-destructive text-sm', props.class)"
/>
</template>

View file

@ -0,0 +1,7 @@
export { default as FormControl } from "./FormControl.vue"
export { default as FormDescription } from "./FormDescription.vue"
export { default as FormItem } from "./FormItem.vue"
export { default as FormLabel } from "./FormLabel.vue"
export { default as FormMessage } from "./FormMessage.vue"
export { FORM_ITEM_INJECTION_KEY } from "./injectionKeys"
export { Form, Field as FormField, FieldArray as FormFieldArray } from "vee-validate"

View file

@ -0,0 +1,4 @@
import type { InjectionKey } from "vue"
export const FORM_ITEM_INJECTION_KEY
= Symbol() as InjectionKey<string>

View file

@ -0,0 +1,30 @@
import { FieldContextKey, useFieldError, useIsFieldDirty, useIsFieldTouched, useIsFieldValid } from "vee-validate"
import { inject } from "vue"
import { FORM_ITEM_INJECTION_KEY } from "./injectionKeys"
export function useFormField() {
const fieldContext = inject(FieldContextKey)
const fieldItemContext = inject(FORM_ITEM_INJECTION_KEY)
if (!fieldContext)
throw new Error("useFormField should be used within <FormField>")
const { name } = fieldContext
const id = fieldItemContext
const fieldState = {
valid: useIsFieldValid(name),
isDirty: useIsFieldDirty(name),
isTouched: useIsFieldTouched(name),
error: useFieldError(name),
}
return {
id,
name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}