diff --git a/packages/vee-validate/src/Field.ts b/packages/vee-validate/src/Field.ts index 4d0d61e6a..1c97532ad 100644 --- a/packages/vee-validate/src/Field.ts +++ b/packages/vee-validate/src/Field.ts @@ -1,4 +1,4 @@ -import { h, defineComponent, nextTick, toRef, SetupContext, resolveDynamicComponent, computed } from 'vue'; +import { h, defineComponent, nextTick, toRef, SetupContext, resolveDynamicComponent, computed, watch } from 'vue'; import { getConfig } from './config'; import { useField } from './useField'; import { normalizeChildren, hasCheckedAttr, isFileInput } from './utils'; @@ -59,6 +59,9 @@ export const Field = defineComponent({ type: null, default: undefined, }, + modelValue: { + type: null, + }, }, setup(props, ctx) { const rules = toRef(props, 'rules'); @@ -87,9 +90,9 @@ export const Field = defineComponent({ // Gets the initial value either from `value` prop/attr or `v-model` binding (modelValue) // For checkboxes and radio buttons it will always be the model value not the `value` attribute initialValue: hasCheckedAttr(ctx.attrs.type) - ? ctx.attrs.modelValue + ? props.modelValue : 'modelValue' in ctx.attrs - ? ctx.attrs.modelValue + ? props.modelValue : ctx.attrs.value, // Only for checkboxes and radio buttons valueProp: ctx.attrs.value, @@ -98,18 +101,9 @@ export const Field = defineComponent({ validateOnValueUpdate: false, }); - let isDuringValueTick = false; - // Prevents re-render updates that rests value when using v-model (#2941) - function valueTick() { - isDuringValueTick = true; - nextTick(() => { - isDuringValueTick = false; - }); - } - // If there is a v-model applied on the component we need to emit the `update:modelValue` whenever the value binding changes const onChangeHandler = - 'modelValue' in ctx.attrs + 'modelValue' in props ? function handleChangeWithModel(e: any) { handleChange(e); ctx.emit('update:modelValue', value.value); @@ -117,7 +111,7 @@ export const Field = defineComponent({ : handleChange; const onInputHandler = - 'modelValue' in ctx.attrs + 'modelValue' in props ? function handleChangeWithModel(e: any) { handleInput(e); ctx.emit('update:modelValue', value.value); @@ -129,18 +123,12 @@ export const Field = defineComponent({ props ); const baseOnBlur = [handleBlur, ctx.attrs.onBlur, validateOnBlur ? validateField : undefined].filter(Boolean); - const baseOnInput = [ - onInputHandler, - valueTick, - validateOnInput ? onChangeHandler : undefined, - ctx.attrs.onInput, - ].filter(Boolean); - const baseOnChange = [ - onInputHandler, - valueTick, - validateOnChange ? onChangeHandler : undefined, - ctx.attrs.onChange, - ].filter(Boolean); + const baseOnInput = [onInputHandler, validateOnInput ? onChangeHandler : undefined, ctx.attrs.onInput].filter( + Boolean + ); + const baseOnChange = [onInputHandler, validateOnChange ? onChangeHandler : undefined, ctx.attrs.onChange].filter( + Boolean + ); const attrs: Record = { name: props.name, @@ -150,7 +138,7 @@ export const Field = defineComponent({ }; if (validateOnModelUpdate) { - attrs['onUpdate:modelValue'] = [onChangeHandler, valueTick]; + attrs['onUpdate:modelValue'] = [onChangeHandler]; } if (hasCheckedAttr(ctx.attrs.type) && checked) { @@ -183,18 +171,19 @@ export const Field = defineComponent({ }; }); + if ('modelValue' in props) { + const modelValue = toRef(props, 'modelValue'); + watch(modelValue, newModelValue => { + if (newModelValue !== value.value) { + value.value = newModelValue; + validateField(); + } + }); + } + return () => { const tag = resolveDynamicComponent(resolveTag(props, ctx)) as string; - // Sync the model value with the inner field value if they mismatch - // a simple string comparison is used here - // make sure to check if the re-render isn't caused by a value update tick - if ('modelValue' in ctx.attrs && String(ctx.attrs.modelValue) !== String(value.value) && !isDuringValueTick) { - nextTick(() => { - handleChange(ctx.attrs.modelValue as any); - }); - } - const children = normalizeChildren(ctx, slotProps.value); if (tag) { diff --git a/packages/vee-validate/src/useField.ts b/packages/vee-validate/src/useField.ts index 6326a052f..36dc0a1fd 100644 --- a/packages/vee-validate/src/useField.ts +++ b/packages/vee-validate/src/useField.ts @@ -22,6 +22,7 @@ import { getFromPath, setInPath, injectWithSelf, + resolveNextCheckboxValue, } from './utils'; import { isCallable } from '../../shared'; import { FieldContextSymbol, FormInitialValuesSymbol, FormContextSymbol } from './symbols'; @@ -116,7 +117,13 @@ export function useField( return; } - value.value = normalizeEventValue(e); + let newValue = normalizeEventValue(e); + // Single checkbox field without a form to toggle it's value + if (checked && type === 'checkbox' && !form) { + newValue = resolveNextCheckboxValue(value.value, unref(valueProp), unref(uncheckedValue)); + } + + value.value = newValue; meta.dirty = true; if (!validateOnValueUpdate) { return validate(); diff --git a/packages/vee-validate/src/useForm.ts b/packages/vee-validate/src/useForm.ts index 8471cd100..fbb2b909a 100644 --- a/packages/vee-validate/src/useForm.ts +++ b/packages/vee-validate/src/useForm.ts @@ -12,7 +12,7 @@ import { FormState, FormValidationResult, } from './types'; -import { getFromPath, isYupValidator, keysOf, setInPath, unsetPath } from './utils'; +import { getFromPath, isYupValidator, keysOf, resolveNextCheckboxValue, setInPath, unsetPath } from './utils'; import { FormErrorsSymbol, FormContextSymbol, FormInitialValuesSymbol } from './symbols'; interface FormOptions> { @@ -139,10 +139,7 @@ export function useForm = Record= 0 ? newVal.splice(idx, 1) : newVal.push(value); + const newVal = resolveNextCheckboxValue(getFromPath(formValues, field as string) || [], value, undefined); setInPath(formValues, field as string, newVal); fieldInstance.forEach(fieldItem => { valuesByFid[fieldItem.fid] = newVal; @@ -153,7 +150,11 @@ export function useForm = Record( + getFromPath(formValues, field as string), + value as TValues[T], + unref(fieldInstance.uncheckedValue) + ); } setInPath(formValues, field as string, newValue); diff --git a/packages/vee-validate/src/utils/common.ts b/packages/vee-validate/src/utils/common.ts index 6dc360ea6..62aa8a080 100644 --- a/packages/vee-validate/src/utils/common.ts +++ b/packages/vee-validate/src/utils/common.ts @@ -146,3 +146,17 @@ export function normalizeField(field: FieldApi | FieldApi[]): FieldApi | undefin return field; } + +export function resolveNextCheckboxValue(currentValue: T, checkedValue: T, uncheckedValue: T): T; +export function resolveNextCheckboxValue(currentValue: T[], checkedValue: T, uncheckedValue: T): T[]; +export function resolveNextCheckboxValue(currentValue: T | T[], checkedValue: T, uncheckedValue: T) { + if (Array.isArray(currentValue)) { + const newVal = [...currentValue]; + const idx = newVal.indexOf(checkedValue); + idx >= 0 ? newVal.splice(idx, 1) : newVal.push(checkedValue); + + return newVal; + } + + return currentValue === checkedValue ? uncheckedValue : checkedValue; +} diff --git a/packages/vee-validate/tests/Field.spec.ts b/packages/vee-validate/tests/Field.spec.ts index 2e99e16a7..b3cfa3643 100644 --- a/packages/vee-validate/tests/Field.spec.ts +++ b/packages/vee-validate/tests/Field.spec.ts @@ -842,4 +842,30 @@ describe('', () => { await flushPromises(); expect(spy).toHaveBeenCalledWith(expect.objectContaining({ terms: false }), expect.anything()); }); + + // #3105 + test('single checkboxes without forms toggles their value with v-model', async () => { + let model!: Ref; + const wrapper = mountWithHoc({ + setup() { + model = ref(false); + + return { model }; + }, + template: ` +
+ Dinner? +
+ `, + }); + + await flushPromises(); + const input = wrapper.$el.querySelector('input'); + setChecked(input, true); + await flushPromises(); + expect(model.value).toBe(true); + setChecked(input, false); + await flushPromises(); + expect(model.value).toBe(false); + }); });