diff --git a/package.json b/package.json index c9d3e908c0..9e014a876c 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ }, "dependencies": { "@ai-sdk/vue": "^2.0.51", + "@formwerk/core": "^0.14.1", "@iconify/vue": "^5.0.0", "@internationalized/date": "^3.9.0", "@internationalized/number": "^3.6.5", diff --git a/playgrounds/nuxt/app/pages/components/form.vue b/playgrounds/nuxt/app/pages/components/form.vue index cf01bd52d8..aa815e1d23 100644 --- a/playgrounds/nuxt/app/pages/components/form.vue +++ b/playgrounds/nuxt/app/pages/components/form.vue @@ -1,9 +1,6 @@ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d0ed02878..4b94d8bbc1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@ai-sdk/vue': specifier: ^2.0.51 version: 2.0.51(vue@3.5.21(typescript@5.8.3))(zod@4.1.11) + '@formwerk/core': + specifier: ^0.14.1 + version: 0.14.1(vue@3.5.21(typescript@5.8.3)) '@iconify/vue': specifier: ^5.0.0 version: 5.0.0(vue@3.5.21(typescript@5.8.3)) @@ -826,6 +829,14 @@ packages: '@floating-ui/vue@1.1.7': resolution: {integrity: sha512-idmAtbAIigGXN2SI5gItiXYBYtNfDTP9yIiObxgu13dgtG7ARCHlNfnR29GxP4LI4o13oiwsJ8wVgghj1lNqcw==} + '@formwerk/core@0.14.1': + resolution: {integrity: sha512-iFC6L16kY+QZYzJ7/1v7oiZUtotGZ2nDxsRJfxd3tuVPnqWu0UWo6fH3Sa+QW+q+9dPHZMyVQTTB16J/V8BJFQ==} + peerDependencies: + vue: '>=3.5.0' + + '@formwerk/devtools@0.14.1': + resolution: {integrity: sha512-CenDXRHxeDkvKfMNFm3SeL3JAXWhtaE6zEpSh0L97UfqOzYlIKJmIatDn1LuN3t4MHd3GJwvaWJlMnvfaJa9Eg==} + '@ghostery/adblocker-content@2.11.6': resolution: {integrity: sha512-K+hJR27fYpRGoUQnza2p0BmN86htN1ASoRxF73mAdf1CNDSyfPNCNlpoKt83Z9CbYBdEglXOWItazB9hRMs7gw==} @@ -2091,6 +2102,9 @@ packages: '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@stylistic/eslint-plugin@5.2.3': resolution: {integrity: sha512-oY7GVkJGVMI5benlBDCaRrSC1qPasafyv5dOBLLv5MTilMGnErKhO6ziEfodDDIZbo5QxPUNW360VudJOFODMw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2612,6 +2626,9 @@ packages: '@vue/devtools-api@6.6.4': resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + '@vue/devtools-api@8.0.2': + resolution: {integrity: sha512-RdwsaYoSTumwZ7XOt5yIPP1/T4O0bTs+c5XaEjmUB6f9x+FvDSL9AekxW1vuhK1lmA9TfewpXVt2r5LIax3LHw==} + '@vue/devtools-core@7.7.7': resolution: {integrity: sha512-9z9TLbfC+AjAi1PQyWX+OErjIaJmdFlbDHcD+cAMYKY6Bh5VlsAtCeGyRMrXwIlMEQPukvnWt3gZBLwTAIMKzQ==} peerDependencies: @@ -2620,9 +2637,15 @@ packages: '@vue/devtools-kit@7.7.7': resolution: {integrity: sha512-wgoZtxcTta65cnZ1Q6MbAfePVFxfM+gq0saaeytoph7nEa7yMXoi6sCPy4ufO111B9msnw0VOWjPEFCXuAKRHA==} + '@vue/devtools-kit@8.0.2': + resolution: {integrity: sha512-yjZKdEmhJzQqbOh4KFBfTOQjDPMrjjBNCnHBvnTGJX+YLAqoUtY2J+cg7BE+EA8KUv8LprECq04ts75wCoIGWA==} + '@vue/devtools-shared@7.7.7': resolution: {integrity: sha512-+udSj47aRl5aKb0memBvcUG9koarqnxNM5yjuREvqwK6T3ap4mn3Zqqc17QrBFTqSMjr3HK1cvStEZpMDpfdyw==} + '@vue/devtools-shared@8.0.2': + resolution: {integrity: sha512-mLU0QVdy5Lp40PMGSixDw/Kbd6v5dkQXltd2r+mdVQV7iUog2NlZuLxFZApFZ/mObUBDhoCpf0T3zF2FWWdeHw==} + '@vue/language-core@3.0.7': resolution: {integrity: sha512-0sqqyqJ0Gn33JH3TdIsZLCZZ8Gr4kwlg8iYOnOrDDkJKSjFurlQY/bEFQx5zs7SX2C/bjMkmPYq/NiyY1fTOkw==} peerDependencies: @@ -7729,6 +7752,21 @@ snapshots: - '@vue/composition-api' - vue + '@formwerk/core@0.14.1(vue@3.5.21(typescript@5.8.3))': + dependencies: + '@formwerk/devtools': 0.14.1 + '@internationalized/date': 3.9.0 + '@standard-schema/spec': 1.0.0 + '@standard-schema/utils': 0.3.0 + klona: 2.0.6 + type-fest: 4.41.0 + vue: 3.5.21(typescript@5.8.3) + + '@formwerk/devtools@0.14.1': + dependencies: + '@vue/devtools-api': 8.0.2 + '@vue/devtools-kit': 8.0.2 + '@ghostery/adblocker-content@2.11.6': dependencies: '@ghostery/adblocker-extended-selectors': 2.11.6 @@ -9234,6 +9272,8 @@ snapshots: '@standard-schema/spec@1.0.0': {} + '@standard-schema/utils@0.3.0': {} + '@stylistic/eslint-plugin@5.2.3(eslint@9.36.0(jiti@2.5.1))': dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.36.0(jiti@2.5.1)) @@ -9772,6 +9812,10 @@ snapshots: '@vue/devtools-api@6.6.4': {} + '@vue/devtools-api@8.0.2': + dependencies: + '@vue/devtools-kit': 8.0.2 + '@vue/devtools-core@7.7.7(vite@7.1.7(@types/node@24.0.7)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.0))(vue@3.5.21(typescript@5.8.3))': dependencies: '@vue/devtools-kit': 7.7.7 @@ -9794,10 +9838,24 @@ snapshots: speakingurl: 14.0.1 superjson: 2.2.2 + '@vue/devtools-kit@8.0.2': + dependencies: + '@vue/devtools-shared': 8.0.2 + birpc: 2.6.1 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 2.0.0 + speakingurl: 14.0.1 + superjson: 2.2.2 + '@vue/devtools-shared@7.7.7': dependencies: rfdc: 1.4.1 + '@vue/devtools-shared@8.0.2': + dependencies: + rfdc: 1.4.1 + '@vue/language-core@3.0.7(typescript@5.8.3)': dependencies: '@volar/language-core': 2.4.23 diff --git a/src/runtime/components/Checkbox.vue b/src/runtime/components/Checkbox.vue index 1c6884ef0f..74f6a36fc2 100644 --- a/src/runtime/components/Checkbox.vue +++ b/src/runtime/components/Checkbox.vue @@ -59,9 +59,10 @@ export interface CheckboxSlots { diff --git a/src/runtime/components/FileUpload.vue b/src/runtime/components/FileUpload.vue index 95348fac93..0864354d4f 100644 --- a/src/runtime/components/FileUpload.vue +++ b/src/runtime/components/FileUpload.vue @@ -126,6 +126,7 @@ export interface FileUploadSlots { import { computed, watch } from 'vue' import { Primitive } from 'reka-ui' import { createReusableTemplate } from '@vueuse/core' +import { useCustomControl } from '@formwerk/core' import { useAppConfig, useLocale } from '#imports' import { useFormField } from '../composables/useFormField' import { useFileUpload } from '../composables/useFileUpload' @@ -163,7 +164,13 @@ const { isDragging, open, inputRef, dropzoneRef } = useFileUpload({ dropzone: props.dropzone, onUpdate }) -const { emitFormInput, emitFormChange, id, name, disabled, ariaAttrs } = useFormField(props) +const { emitFormInput, emitFormChange, size: formFieldSize, highlight, color, name, disabled } = useFormField(props) +const { controlProps } = useCustomControl({ + name, + disabled, + required: props.required, + controlType: 'UFileUpload' +}) const variant = computed(() => props.multiple ? 'area' : props.variant) const layout = computed(() => props.variant === 'button' && !props.multiple ? 'grid' : props.layout) @@ -181,14 +188,14 @@ const position = computed(() => { const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.fileUpload || {}) })({ dropzone: props.dropzone, interactive: props.interactive, - color: props.color, - size: props.size, + color: color.value, + size: formFieldSize.value || props.size, variant: variant.value, layout: layout.value, position: position.value, multiple: props.multiple, - highlight: props.highlight, - disabled: props.disabled + highlight: highlight.value, + disabled: disabled.value })) function createObjectUrl(file: File): string { @@ -363,7 +370,6 @@ defineExpose({ diff --git a/src/runtime/components/Form.vue b/src/runtime/components/Form.vue index 653900c4a1..bf1f5db493 100644 --- a/src/runtime/components/Form.vue +++ b/src/runtime/components/Form.vue @@ -1,23 +1,24 @@ - diff --git a/src/runtime/components/FormField.vue b/src/runtime/components/FormField.vue index 87e523d996..d77fb402c8 100644 --- a/src/runtime/components/FormField.vue +++ b/src/runtime/components/FormField.vue @@ -47,41 +47,31 @@ export interface FormFieldSlots { @@ -101,19 +87,19 @@ provide(formFieldInjectionKey, computed(() => ({
-
-

+

{{ description }} @@ -123,12 +109,12 @@ provide(formFieldInjectionKey, computed(() => ({

-
+
{{ error }}
-
+
{{ help }} diff --git a/src/runtime/components/Input.vue b/src/runtime/components/Input.vue index aed1b12f1d..30f741b696 100644 --- a/src/runtime/components/Input.vue +++ b/src/runtime/components/Input.vue @@ -65,6 +65,7 @@ export interface InputSlots { import { ref, computed, onMounted } from 'vue' import { Primitive } from 'reka-ui' import { useVModel } from '@vueuse/core' +import { useCustomControl } from '@formwerk/core' import { useAppConfig } from '#imports' import { useFieldGroup } from '../composables/useFieldGroup' import { useComponentIcons } from '../composables/useComponentIcons' @@ -88,7 +89,13 @@ const modelValue = useVModel, 'modelValue', 'update:modelValue'>(p const appConfig = useAppConfig() as Input['AppConfig'] -const { emitFormBlur, emitFormInput, emitFormChange, size: formGroupSize, color, id, name, highlight, disabled, emitFormFocus, ariaAttrs } = useFormField>(props, { deferInputValidation: true }) +const { size: formGroupSize, color, name, highlight, disabled, emitFormDirty, emitFormTouched, emitFormBlur } = useFormField>(props, { deferInputValidation: true }) +const { controlProps, field: { isDisabled, setBlurred, setTouched, setValue } } = useCustomControl({ + name, + disabled, + required: props.required, + controlType: 'UInput' +}) const { orientation, size: fieldGroupSize } = useFieldGroup>(props) const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props) @@ -127,7 +134,10 @@ function updateInput(value: string | null | undefined) { } modelValue.value = value as T - emitFormInput() + setValue(value) + setTouched(true) + emitFormDirty() + emitFormTouched() } function onInput(event: Event) { @@ -148,13 +158,16 @@ function onChange(event: Event) { (event.target as HTMLInputElement).value = value.trim() } - emitFormChange() emits('change', event) } function onBlur(event: FocusEvent) { - emitFormBlur() emits('blur', event) + + setTouched(true) + setBlurred(true) + emitFormBlur() + emitFormTouched() } function autoFocus() { @@ -177,21 +190,18 @@ defineExpose({