chore(primevue): F3 — PrimeVue foundation with parallel-mode operation #24

Merged
bert.hausmans merged 10 commits from chore/f3-primevue-foundation into main 2026-05-11 20:07:58 +02:00
2 changed files with 137 additions and 0 deletions
Showing only changes of commit c1190ab045 - Show all commits

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
// Crewli FormField wrapper — terse call-site syntax that hides the
// @primevue/forms validation plumbing. Per RFC Appendix A.
//
// Usage:
// <Form :resolver="zodResolver(schema)" @submit="onSubmit">
// <FormField name="email" label="E-mailadres" required>
// <InputText name="email" />
// </FormField>
// </Form>
//
// The default slot accepts any PrimeVue input whose `name` matches the
// FormField's `name` prop. The parent <Form> auto-registers each named
// input. This wrapper renders the surrounding label, error message,
// and hint, reading the field state from the parent Form context via
// @primevue/forms' built-in <FormField> scoped slot.
//
// Error precedence: apiError prop > useFormError() provide/inject map >
// Zod resolver error. The first non-empty source wins.
import { type Ref, computed, inject } from 'vue'
import { FormField as PrimeFormField } from '@primevue/forms'
import Message from 'primevue/message'
import {
FORM_API_ERRORS_KEY,
type FormApiErrorsMap,
} from '@/composables/useFormError'
interface Props {
name: string
label?: string
required?: boolean
hint?: string
apiError?: string | null
}
const props = withDefaults(defineProps<Props>(), {
label: undefined,
required: false,
hint: undefined,
apiError: null,
})
const injectedApiErrors = inject<Ref<FormApiErrorsMap> | null>(
FORM_API_ERRORS_KEY,
null,
)
const resolvedApiError = computed<string | null>(() => {
if (props.apiError)
return props.apiError
return injectedApiErrors?.value[props.name] ?? null
})
</script>
<template>
<PrimeFormField
v-slot="$field"
:name="name"
>
<div class="crewli-field mb-4 flex flex-col gap-1">
<label
v-if="label"
:for="name"
class="text-sm font-medium"
>
{{ label }}
<span
v-if="required"
aria-label="verplicht"
class="text-red-500"
>*</span>
</label>
<slot />
<Message
v-if="resolvedApiError || ($field?.invalid && $field?.error)"
severity="error"
size="small"
variant="simple"
>
{{ resolvedApiError ?? $field.error?.message ?? $field.errors?.[0]?.message }}
</Message>
<small
v-else-if="hint"
class="text-surface-500"
>{{ hint }}</small>
</div>
</PrimeFormField>
</template>

View File

@@ -0,0 +1,44 @@
// useFormError — merges Laravel 422 validation responses into the
// surrounding Form's per-field error display.
//
// Pattern: a parent component calls useFormError() once. The composable
// provides an apiErrors map via Vue inject. Each FormField inside the
// component reads its own field name from that map and falls through to
// its $field.error (the Zod resolver result) when no API error is set.
// apiError precedence > Zod error per RFC Appendix A.
//
// Crewli backend 422 shape: { message, errors: { field: string[] } }.
// We surface the first message per field.
import { type Ref, provide, ref } from 'vue'
export const FORM_API_ERRORS_KEY = Symbol('crewli-form-api-errors')
export type FormApiErrorsMap = Record<string, string>
export interface UseFormErrorReturn {
applyApiErrors: (errors: Record<string, string[]>) => void
clearApiErrors: () => void
apiErrors: Ref<FormApiErrorsMap>
}
export function useFormError(): UseFormErrorReturn {
const apiErrors = ref<FormApiErrorsMap>({})
provide(FORM_API_ERRORS_KEY, apiErrors)
function applyApiErrors(errors: Record<string, string[]>): void {
const next: FormApiErrorsMap = {}
for (const [field, messages] of Object.entries(errors)) {
if (Array.isArray(messages) && messages.length > 0)
next[field] = messages[0]
}
apiErrors.value = next
}
function clearApiErrors(): void {
apiErrors.value = {}
}
return { applyApiErrors, clearApiErrors, apiErrors }
}