Repository: logaretm/vee-validate
Pull request #4249 (2023-05-09 14:48:44)

add `inlang` to make the contribution of translations easier
🔎 __Overview__

This pull request adds the possibility for contributors and translators to manage translations in a UI instead of files with no overhead for the maintainers.

To get a UI for translations contained in this repository, an [inlang.config.js](https://inlang.com/documentation/getting-started) has been created at the root of the repository.

🤓 __Preview__

The changes of this PR and a live instance of the editor can be previewed with the following link https://inlang.com/editor/github.com/NiklasBuchfink/vee-validate. 
**Note:** This link should be changed to point to logaretm instead of NiklasBuchfink after this PR has been merged.

![inlang Editor](https://user-images.githubusercontent.com/58360188/232016041-ec0c3da3-94a9-4492-85dd-d8478c66d945.jpeg)

🚧 __Limitations__

**Certain actions are slow**
Inlang is running entirely on git, giving tremendous CI/CD and contribution power to localization (and potentially other verticals -> [the next git](https://www.youtube.com/watch?v=vJ3jGgCrz2I)). That means that the whole nostr-how repo is cloned into the browser which makes certain actions like the initial load and pushing changes slow. Those limitations will be fixed with future releases and require no input from logaretm.

🏷️ __Badge__
If you want to show the contributor the status of the translations, you could add a badge to your readme and documentation.

```
[![translation badge](https://inlang.com/badge?url=github.com/logaretm/vee-validate)](https://inlang.com/editor/github.com/logaretm/vee-validate?ref=badge)
```

Preview the messages on https://inlang.com/editor/github.com/NiklasBuchfink/vee-validate.

codecov-commenter: ## [Codecov](https://app.codecov.io/gh/logaretm/vee-validate/pull/4249?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abdelrahman+Awad) Report
> Merging [#4249](https://app.codecov.io/gh/logaretm/vee-validate/pull/4249?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abdelrahman+Awad) (5dad7b3) into [main](https://app.codecov.io/gh/logaretm/vee-validate/commit/dd320ea7f710defe790733f3f03bcf0afc5dcbab?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abdelrahman+Awad) (dd320ea) will **not change** coverage.
> The diff coverage is `n/a`.

:exclamation: Your organization is not using the GitHub App Integration. As a result you may experience degraded service beginning May 15th. Please [install the Github App Integration](https://github.com/apps/codecov) for your organization. [Read more](https://about.codecov.io/blog/codecov-is-updating-its-github-integration/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abdelrahman+Awad).

```diff
@@           Coverage Diff           @@
##             main    #4249   +/-   ##
=======================================
  Coverage   98.10%   98.10%           
=======================================
  Files          73       73           
  Lines        5749     5749           
  Branches     1092     1092           
=======================================
  Hits         5640     5640           
  Misses        109      109           
```



:mega: We’re building smart automated test selection to slash your CI/CD build times. [Learn more](https://about.codecov.io/iterative-testing/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abdelrahman+Awad)


Patch:
diff --git a/.changeset/lazy-years-mate.md b/.changeset/lazy-years-mate.md
new file mode 100644
index 00000000..038525dc
--- /dev/null
+++ b/.changeset/lazy-years-mate.md
@@ -0,0 +1,5 @@
+---
+'vee-validate': patch
+---
+
+fix: use event value if no checked value for checkbox/radio closes #4308
diff --git a/.changeset/little-kings-perform.md b/.changeset/little-kings-perform.md
new file mode 100644
index 00000000..66989577
--- /dev/null
+++ b/.changeset/little-kings-perform.md
@@ -0,0 +1,5 @@
+---
+'vee-validate': minor
+---
+
+"feat: allow handleBlur to run validations"
diff --git a/.changeset/loud-frogs-explode.md b/.changeset/loud-frogs-explode.md
new file mode 100644
index 00000000..31b0137c
--- /dev/null
+++ b/.changeset/loud-frogs-explode.md
@@ -0,0 +1,5 @@
+---
+'vee-validate': patch
+---
+
+feat(dx): make `syncVModel` accept the model propName
diff --git a/README.md b/README.md
index 8a099f56..48006dea 100644
--- a/README.md
+++ b/README.md
@@ -164,6 +164,11 @@ Read the [documentation and demos](https://vee-validate.logaretm.com/v4).
 
 You are welcome to contribute to this project, but before you do, please make sure you read the [contribution guide](/CONTRIBUTING.md).
 
+## Translations 🌎🗺
+[![translation badge](https://inlang.com/badge?url=github.com/logaretm/vee-validate)](https://inlang.com/editor/github.com/logaretm/vee-validate?ref=badge)
+
+To add translations, you can manually edit the JSON translation files in `packages/i18n/src/locale`, use the [inlang](https://inlang.com/) online editor, or run `pnpm machine-translate` to add missing translations using AI from Inlang.
+
 ## Credits
 
 - Inspired by Laravel's [validation syntax](https://laravel.com/docs/5.4/validation)
diff --git a/docs/src/components/SiteHead.astro b/docs/src/components/SiteHead.astro
index d1254bf6..e4c70cdb 100644
--- a/docs/src/components/SiteHead.astro
+++ b/docs/src/components/SiteHead.astro
@@ -33,5 +33,5 @@ const links = generateLinks({
   {metaTags.map(t => <meta content={t.content} name={t.name} property={t.property} />)}
   {links.map(t => <link href={t.href} rel={t.rel} />)}
   <script is:inline src="/v4/loadTheme.js"></script>
-  <script src="https://cdn.usefathom.com/script.js" data-site="OYTLUCCD" defer></script>
+  <!-- <script src="https://cdn.usefathom.com/script.js" data-site="OYTLUCCD" defer></script> -->
 </head>
diff --git a/docs/src/layouts/HomeLayout.astro b/docs/src/layouts/HomeLayout.astro
index e397a07b..d6129507 100644
--- a/docs/src/layouts/HomeLayout.astro
+++ b/docs/src/layouts/HomeLayout.astro
@@ -46,9 +46,5 @@ const menu = [
       </main>
       <Ad client:idle placement="home-page" />
     </div>
-
-    <!-- Fathom - beautiful, simple website analytics -->
-    <script src="https://cdn.usefathom.com/script.js" data-site="OYTLUCCD" defer></script>
-    <!-- / Fathom -->
   </body>
 </html>
diff --git a/inlang.config.js b/inlang.config.js
new file mode 100644
index 00000000..04f9ab2a
--- /dev/null
+++ b/inlang.config.js
@@ -0,0 +1,17 @@
+export async function defineConfig(env) {
+	const { default: pluginJson } = await env.$import(
+		'https://cdn.jsdelivr.net/gh/samuelstroschein/inlang-plugin-json@2/dist/index.js'
+	);
+
+	const { default: standardLintRules } = await env.$import(
+		'https://cdn.jsdelivr.net/gh/inlang/standard-lint-rules@2/dist/index.js'
+	);
+
+	return {
+		referenceLanguage: 'en',
+		plugins: [pluginJson({ 
+			pathPattern: './packages/i18n/src/locale/{language}.json',
+			variableReferencePattern: ["{", "}"]
+		}), standardLintRules()]
+	};
+}
diff --git a/packages/i18n/src/locale/kz.json b/packages/i18n/src/locale/kz.json
new file mode 100644
index 00000000..435d63f7
--- /dev/null
+++ b/packages/i18n/src/locale/kz.json
@@ -0,0 +1,32 @@
+{
+  "code": "kz",
+  "messages": {
+    "_default": "{field} жолағы дұрыс емес",
+    "alpha": "{field} жолағына тек әріптер жазуға болады",
+    "alpha_num": "{field} жолағына тек әріптер және сандар жазуға болады",
+    "alpha_dash": "{field} жолағына тек әріптер, сандар және дефис жазуға болады",
+    "alpha_spaces": "{field} жолағына тек әріптер және бос орын жазуға болады",
+    "between": "{field} жолағы 0:{min} және 1:{max} сандарының арасындағы сан болуға тиіс",
+    "confirmed": "{field} жолағы басқа жолақпен бірдей емес",
+    "digits": "{field} жолағы тура 0:{length} цифрдан тұратын сан болуға тиіс",
+    "dimensions": "{field} жолағы 1:{height} пиксельге 0:{width} пиксель болатын сурет болуға тиіс",
+    "email": "{field} жолағы қолданыстағы электронды мекенжай болуға тиіс",
+    "not_one_of": "{field} жолағы рұқсат етілген мән болуға тиіс",
+    "ext": "{field} жолағы қолданыстағы файл болуға тиіс",
+    "image": "{field} жолағы сурет болуға тиіс",
+    "integer": "{field} жолағы бүтін сан болуға тиіс",
+    "length": "{field} жолағының ұзындығы  мына мәннен ұзын болуға тиіс: 0:{length}",
+    "max_value": "{field} жолағы 0:{max} не одан кіші сан болуға тиіс",
+    "max": "{field} жолағы 0:{length} символдан ұзын бола алмайды",
+    "mimes": "{field} жолағында рұқсат етілген файл типі болуға тиіс",
+    "min_value": "{field} жолағы 0:{min} не одан үлкен сан болуға тиіс",
+    "min": "{field} жолағы 0:{length} символдан қысқа болмауға тиіс",
+    "numeric": "{field} жолағы сан болуға тиіс",
+    "one_of": "{field} жолағы рұқсат етілген мән болуға тиіс",
+    "regex": "{field} жолағының форматы дұрыс емес",
+    "required_if": "{field} жолағын толтыру міндетті",
+    "required": "{field} жолағын толтыру міндетті",
+    "size": "{field} жолағы 0:{size}KB-ден кіші болуға тиіс",
+    "url": "{field} жолағында форматы дұрыс емес сілтеме бар"
+  }
+}
diff --git a/packages/rules/src/alpha_helper.ts b/packages/rules/src/alpha_helper.ts
index 2829e64f..65bbf9fd 100644
--- a/packages/rules/src/alpha_helper.ts
+++ b/packages/rules/src/alpha_helper.ts
@@ -18,6 +18,7 @@ export const alpha: { [k: string]: RegExp } = {
   pl: /^[A-ZĄĆĘŚŁŃÓŻŹ]*$/i,
   pt: /^[A-ZÃÁÀÂÇÉÊÍÕÓÔÚÜ]*$/i,
   ru: /^[А-ЯЁ]*$/i,
+  kz: /^[А-ЯЁ\u04D8\u04B0\u0406\u04A2\u0492\u04AE\u049A\u04E8\u04BA]*$/i,
   sk: /^[A-ZÁÄČĎÉÍĹĽŇÓŔŠŤÚÝŽ]*$/i,
   sr: /^[A-ZČĆŽŠĐ]*$/i,
   sv: /^[A-ZÅÄÖ]*$/i,
@@ -42,6 +43,7 @@ export const alphaSpaces: { [k: string]: RegExp } = {
   pl: /^[A-ZĄĆĘŚŁŃÓŻŹ\s]*$/i,
   pt: /^[A-ZÃÁÀÂÇÉÊÍÕÓÔÚÜ\s]*$/i,
   ru: /^[А-ЯЁ\s]*$/i,
+  kz: /^[А-ЯЁ\u04D8\u04B0\u0406\u04A2\u0492\u04AE\u049A\u04E8\u04BA\s]*$/i,
   sk: /^[A-ZÁÄČĎÉÍĹĽŇÓŔŠŤÚÝŽ\s]*$/i,
   sr: /^[A-ZČĆŽŠĐ\s]*$/i,
   sv: /^[A-ZÅÄÖ\s]*$/i,
@@ -66,6 +68,7 @@ export const alphanumeric: { [k: string]: RegExp } = {
   pl: /^[0-9A-ZĄĆĘŚŁŃÓŻŹ]*$/i,
   pt: /^[0-9A-ZÃÁÀÂÇÉÊÍÕÓÔÚÜ]*$/i,
   ru: /^[0-9А-ЯЁ]*$/i,
+  kz: /^[0-9А-ЯЁ\u04D8\u04B0\u0406\u04A2\u0492\u04AE\u049A\u04E8\u04BA]*$/i,
   sk: /^[0-9A-ZÁÄČĎÉÍĹĽŇÓŔŠŤÚÝŽ]*$/i,
   sr: /^[0-9A-ZČĆŽŠĐ]*$/i,
   sv: /^[0-9A-ZÅÄÖ]*$/i,
@@ -90,6 +93,7 @@ export const alphaDash: { [k: string]: RegExp } = {
   pl: /^[0-9A-ZĄĆĘŚŁŃÓŻŹ_-]*$/i,
   pt: /^[0-9A-ZÃÁÀÂÇÉÊÍÕÓÔÚÜ_-]*$/i,
   ru: /^[0-9А-ЯЁ_-]*$/i,
+  kz: /^[0-9А-ЯЁ\u04D8\u04B0\u0406\u04A2\u0492\u04AE\u049A\u04E8\u04BA_-]*$/i,
   sk: /^[0-9A-ZÁÄČĎÉÍĹĽŇÓŔŠŤÚÝŽ_-]*$/i,
   sr: /^[0-9A-ZČĆŽŠĐ_-]*$/i,
   sv: /^[0-9A-ZÅÄÖ_-]*$/i,
diff --git a/packages/vee-validate/src/Field.ts b/packages/vee-validate/src/Field.ts
index 54a66985..a7b80b00 100644
--- a/packages/vee-validate/src/Field.ts
+++ b/packages/vee-validate/src/Field.ts
@@ -161,14 +161,10 @@ const FieldImpl = /** #__PURE__ */ defineComponent({
         resolveValidationTriggers(props);
 
       function baseOnBlur(e: Event) {
-        handleBlur(e);
+        handleBlur(e, validateOnBlur);
         if (isCallable(ctx.attrs.onBlur)) {
           ctx.attrs.onBlur(e);
         }
-
-        if (validateOnBlur) {
-          validateField();
-        }
       }
 
       function baseOnInput(e: Event | unknown) {
diff --git a/packages/vee-validate/src/types/forms.ts b/packages/vee-validate/src/types/forms.ts
index 36f5b2e9..21b541bb 100644
--- a/packages/vee-validate/src/types/forms.ts
+++ b/packages/vee-validate/src/types/forms.ts
@@ -137,7 +137,7 @@ export interface PrivateFieldContext<TValue = unknown> {
   handleReset(): void;
   validate: FieldValidator;
   handleChange(e: Event | unknown, shouldValidate?: boolean): void;
-  handleBlur(e?: Event): void;
+  handleBlur(e?: Event, shouldValidate?: boolean): void;
   setState(state: Partial<FieldState<TValue>>): void;
   setTouched(isTouched: boolean): void;
   setErrors(message: string | string[]): void;
diff --git a/packages/vee-validate/src/useField.ts b/packages/vee-validate/src/useField.ts
index bf3e7e10..ad122eeb 100644
--- a/packages/vee-validate/src/useField.ts
+++ b/packages/vee-validate/src/useField.ts
@@ -55,6 +55,9 @@ export interface FieldOptions<TValue = unknown> {
   validateOnMount?: boolean;
   bails?: boolean;
   type?: InputType;
+  /**
+   * @deprecated Use `checkedValue` instead.
+   */
   valueProp?: MaybeRef<TValue>;
   checkedValue?: MaybeRef<TValue>;
   uncheckedValue?: MaybeRef<TValue>;
@@ -62,8 +65,11 @@ export interface FieldOptions<TValue = unknown> {
   controlled?: boolean;
   standalone?: boolean;
   keepValueOnUnmount?: MaybeRef<boolean | undefined>;
+  /**
+   * @deprecated Pass the model prop name to `syncVModel` instead.
+   */
   modelPropName?: string;
-  syncVModel?: boolean;
+  syncVModel?: boolean | string;
   form?: FormContext;
 }
 
@@ -107,7 +113,6 @@ function _useField<TValue = unknown>(
     uncheckedValue,
     controlled,
     keepValueOnUnmount,
-    modelPropName,
     syncVModel,
     form: controlForm,
   } = normalizeOptions(opts);
@@ -148,14 +153,17 @@ function _useField<TValue = unknown>(
   const errorMessage = computed(() => errors.value[0]);
 
   if (syncVModel) {
-    useVModel({ value, prop: modelPropName, handleChange });
+    useVModel({ value, prop: syncVModel, handleChange });
   }
 
   /**
    * Handles common onBlur meta update
    */
-  const handleBlur = () => {
+  const handleBlur = (evt?: unknown, shouldValidate = false) => {
     meta.touched = true;
+    if (shouldValidate) {
+      validateWithStateMutation();
+    }
   };
 
   async function validateCurrentValue(mode: SchemaValidationMode) {
@@ -254,11 +262,6 @@ function _useField<TValue = unknown>(
 
   function setValue(newValue: TValue, shouldValidate = true) {
     value.value = newValue;
-    if (!shouldValidate) {
-      validateValidStateOnly();
-      return;
-    }
-
     const validateFn = shouldValidate ? validateWithStateMutation : validateValidStateOnly;
     validateFn();
   }
@@ -451,15 +454,15 @@ function normalizeOptions<TValue>(opts: Partial<FieldOptions<TValue>> | undefine
     label: undefined,
     validateOnValueUpdate: true,
     keepValueOnUnmount: undefined,
-    modelPropName: 'modelValue',
     syncVModel: false,
     controlled: true,
   });
 
-  const isVModelSynced = opts?.syncVModel ?? true;
+  const isVModelSynced = !!opts?.syncVModel;
+  const modelPropName = typeof opts?.syncVModel === 'string' ? opts.syncVModel : opts?.modelPropName || 'modelValue';
   const initialValue =
     isVModelSynced && !('initialValue' in (opts || {}))
-      ? getCurrentModelValue(getCurrentInstance(), opts?.modelPropName || 'modelValue')
+      ? getCurrentModelValue(getCurrentInstance(), modelPropName)
       : opts?.initialValue;
 
   if (!opts) {
@@ -469,6 +472,7 @@ function normalizeOptions<TValue>(opts: Partial<FieldOptions<TValue>> | undefine
   // TODO: Deprecate this in next major release
   const checkedValue = 'valueProp' in opts ? opts.valueProp : opts.checkedValue;
   const controlled = 'standalone' in opts ? !opts.standalone : opts.controlled;
+  const syncVModel = opts?.modelPropName || opts?.syncVModel || false;
 
   return {
     ...defaults(),
@@ -476,6 +480,7 @@ function normalizeOptions<TValue>(opts: Partial<FieldOptions<TValue>> | undefine
     initialValue,
     controlled: controlled ?? true,
     checkedValue,
+    syncVModel,
   } as FieldOptions<TValue>;
 }
 
@@ -513,12 +518,11 @@ function useFieldWithChecked<TValue = unknown>(
       const path = toValue(name);
       const pathState = form?.getPathState(path);
       const value = normalizeEventValue(e);
-      let newValue!: TValue;
+      let newValue = unref(checkedValue) ?? value;
       if (form && pathState?.multiple && pathState.type === 'checkbox') {
-        newValue = resolveNextCheckboxValue(getFromPath(form.values, path) || [], value, undefined) as TValue;
-      } else {
-        // Single checkbox field without a form to toggle it's value
-        newValue = resolveNextCheckboxValue(unref(field.value), unref(checkedValue), unref(uncheckedValue)) as TValue;
+        newValue = resolveNextCheckboxValue(getFromPath(form.values, path) || [], newValue, undefined) as TValue;
+      } else if (opts?.type === 'checkbox') {
+        newValue = resolveNextCheckboxValue(unref(field.value), newValue, unref(uncheckedValue)) as TValue;
       }
 
       handleChange(newValue, shouldValidate);
@@ -537,7 +541,7 @@ function useFieldWithChecked<TValue = unknown>(
 }
 
 interface ModelOpts<TValue> {
-  prop?: string;
+  prop: string | boolean;
   value: Ref<TValue>;
   handleChange: FieldContext['handleChange'];
 }
@@ -545,14 +549,14 @@ interface ModelOpts<TValue> {
 function useVModel<TValue = unknown>({ prop, value, handleChange }: ModelOpts<TValue>) {
   const vm = getCurrentInstance();
   /* istanbul ignore next */
-  if (!vm) {
+  if (!vm || !prop) {
     if (__DEV__) {
       console.warn('Failed to setup model events because `useField` was not called in setup.');
     }
     return;
   }
 
-  const propName = prop || 'modelValue';
+  const propName = typeof prop === 'string' ? prop : 'modelValue';
   const emitName = `update:${propName}`;
 
   // Component doesn't have a model prop setup (must be defined on the props)


Test patch:
diff --git a/packages/vee-validate/tests/Form.spec.ts b/packages/vee-validate/tests/Form.spec.ts
index 12599c87..a8a89112 100644
--- a/packages/vee-validate/tests/Form.spec.ts
+++ b/packages/vee-validate/tests/Form.spec.ts
@@ -3150,3 +3150,46 @@ test('unmounted fields should not be validated when keep-values is on', async ()
   };
   expect(spy).toHaveBeenLastCalledWith(expected);
 });
+
+// #4308
+test('radio fields with single field component binding', async () => {
+  const submit = vi.fn();
+  const model = ref('');
+  const wrapper = mountWithHoc({
+    setup() {
+      return {
+        onSubmit: submit,
+        model,
+      };
+    },
+    template: `
+      <VForm @submit="onSubmit">
+        <Field name="drink" type="radio" v-model="model" v-slot="{ field }">
+          <input type="radio" value="Coffee" v-bind="field" />
+          <input type="radio" value="Tea" v-bind="field" />
+        </Field>
+
+        <button>Submit</button>
+      </VForm>
+    `,
+  });
+
+  const inputs = wrapper.$el.querySelectorAll('input');
+  const button = wrapper.$el.querySelector('button');
+
+  wrapper.$el.querySelector('button').click();
+  await flushPromises();
+
+  setChecked(inputs[0]);
+  await flushPromises();
+  button.click();
+  await flushPromises();
+  expect(model.value).toBe('Coffee');
+  expect(submit).toHaveBeenLastCalledWith({ drink: 'Coffee' }, expect.anything());
+  setChecked(inputs[1]);
+  await flushPromises();
+  button.click();
+  await flushPromises();
+  expect(model.value).toBe('Tea');
+  expect(submit).toHaveBeenLastCalledWith({ drink: 'Tea' }, expect.anything());
+});
diff --git a/packages/vee-validate/tests/useField.spec.ts b/packages/vee-validate/tests/useField.spec.ts
index 3e2cbba5..be68f5e7 100644
--- a/packages/vee-validate/tests/useField.spec.ts
+++ b/packages/vee-validate/tests/useField.spec.ts
@@ -604,7 +604,7 @@ describe('useField()', () => {
         modelValue: String,
       },
       setup() {
-        const { value, errorMessage } = useField('field');
+        const { value, errorMessage } = useField('field', undefined, { syncVModel: true });
 
         return {
           value,