-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Submit
+
-
-
-
+
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({
diff --git a/src/runtime/components/InputMenu.vue b/src/runtime/components/InputMenu.vue
index 3f22908464..d3b3d473b9 100644
--- a/src/runtime/components/InputMenu.vue
+++ b/src/runtime/components/InputMenu.vue
@@ -178,6 +178,7 @@ import { ComboboxRoot, ComboboxArrow, ComboboxAnchor, ComboboxInput, ComboboxTri
import { defu } from 'defu'
import { isEqual } from 'ohash/utils'
import { reactivePick, createReusableTemplate } from '@vueuse/core'
+import { useCustomControl } from '@formwerk/core'
import { useAppConfig } from '#imports'
import { useFieldGroup } from '../composables/useFieldGroup'
import { useComponentIcons } from '../composables/useComponentIcons'
@@ -214,7 +215,13 @@ const portalProps = usePortal(toRef(() => props.portal))
const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8, collisionPadding: 8, position: 'popper' }) as ComboboxContentProps)
const arrowProps = toRef(() => props.arrow as ComboboxArrowProps)
-const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField(props)
+const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, size: formGroupSize, color, name, highlight, disabled } = useFormField(props)
+const { controlProps } = useCustomControl({
+ name,
+ disabled,
+ required: props.required,
+ controlType: 'UInputMenu'
+})
const { orientation, size: fieldGroupSize } = useFieldGroup(props)
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(toRef(() => defu(props, { trailingIcon: appConfig.ui.icons.chevronDown })))
@@ -457,9 +464,8 @@ defineExpose({
(props)
+const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, color, size: formGroupSize, name, highlight, disabled } = useFormField(props)
+const { controlProps } = useCustomControl({
+ name,
+ disabled,
+ required: props.required,
+ controlType: 'UInputNumber'
+})
const { orientation, size: fieldGroupSize } = useFieldGroup(props)
const locale = computed(() => props.locale || codeLocale.value)
@@ -171,7 +178,7 @@ defineExpose({
@update:model-value="onUpdate"
>
{
import { computed, ref, onMounted, toRaw } from 'vue'
import { TagsInputRoot, TagsInputItem, TagsInputItemText, TagsInputItemDelete, TagsInputInput, useForwardPropsEmits } from 'reka-ui'
import { reactivePick } 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 appConfig = useAppConfig() as InputTags['AppConfig']
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'addOnPaste', 'addOnTab', 'addOnBlur', 'duplicate', 'delimiter', 'max', 'convertValue', 'displayValue', 'required'), emits)
-const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField(props)
+const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, size: formGroupSize, color, name, highlight, disabled } = useFormField(props)
+const { controlProps } = useCustomControl({
+ name,
+ disabled,
+ required: props.required,
+ controlType: 'UInputTags'
+})
const { orientation, size: fieldGroupSize } = useFieldGroup(props)
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props)
@@ -180,7 +187,7 @@ defineExpose({
(props)
+const { emitFormInput, emitFormFocus, emitFormChange, emitFormBlur, size, color, name, highlight, disabled } = useFormField(props)
+const { controlProps, field: { isDisabled } } = useCustomControl({
+ name,
+ disabled,
+ required: props.required,
+ controlType: 'UPinInput'
+})
const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.pinInput || {}) })({
color: color.value,
@@ -113,8 +120,7 @@ defineExpose({
diff --git a/src/runtime/components/RadioGroup.vue b/src/runtime/components/RadioGroup.vue
index f07b213a10..245b34095e 100644
--- a/src/runtime/components/RadioGroup.vue
+++ b/src/runtime/components/RadioGroup.vue
@@ -87,9 +87,10 @@ export interface RadioGroupSlots
-
+