Repository: baianat/vee-validate (js)

Issue:
[Feature request] Renderless components
**Is your feature request related to a problem? Please describe.**
Deep styling components wrapped with `ValidationObserver` or `ValidationProvider` is more complex as a result of a wrapping element being present in the DOM. I am able to work around this for now but the solution I currently utilize is not ideal.

**Describe the solution you'd like**
Support a property that allows these components to be rendered without the wrapping DOM element.

**Describe alternatives you've considered**
Simply waiting for Vue 3 to natively support fragments should make the wrapping element unnecessary and this property not necessary either.

**Additional context**
[portal-vue](https://linusborg.github.io/portal-vue/#/guide?id=removing-the-wrapper-element) allows a `slim` property to be specified for its `portal-target` component to remove the wrapping element. Can the same thing be done here? In my use case, I know I will always have one root node so this should work.

Pull request:
[feat] add `slim` prop to force renderless rendering
🔎 __Overview__

The validation components are considered to be `Provider` in terms of design and functionality, but it doesn't make much sense to allow them to render anything of their own (`span` by default). But it is required due to the limitations of the Vue rendering engine not allowing multiple root nodes in a component.

This PR adds a `slim` prop to force the components to be renderless, they will only render the first child in their slot. This is inspired by the `slim` prop in [vue-portal](https://github.com/LinusBorg/portal-vue)

When Vue 3.0 comes out we might switch to renderless by default if possible since fragments will allow us to have multiple root nodes.

```vue
<ValidationObserver v-slot="ctx" slim>
  <form></form>
</ValidationObserver>
```

✔ __Issues affected__

closes #2072


Patch:
diff --git a/docs/guide/components/validation-observer.md b/docs/guide/components/validation-observer.md
index 8f584102..8eb92254 100644
--- a/docs/guide/components/validation-observer.md
+++ b/docs/guide/components/validation-observer.md
@@ -38,6 +38,31 @@ You can expand upon this by adding your form listeners like `submit` on the obse
 </ValidationObserver>
 ```
 
+### Renderless
+
+Sometimes it is unsuitable for a Provider component in principle to render anything extra, because of limitations in the Vue rendering engine we cannot have multiple root nodes which limits the design choice to move away from renderless at the moment, in Vue 3.x it this may change with fragments.
+
+In 2.2.10 a `slim` prop can be used to force the component to be renderless, by default it is set to `false`.
+
+```vue
+<!-- Only the stuff inside the observer will be rendered -->
+<ValidationObserver slim>
+  <form>
+    <!-- Fields -->
+  </form>
+</ValidationObserver>
+```
+
+Note that **only the first child** will be rendered when `slim` is used, any other nodes will be dropped as you cannot have multiple root nodes in a renderless component. Be mindful of that when using the `slim` prop.
+
+```vue
+<!-- Only form is rendered. -->
+<ValidationObserver slim>
+  <form></form>
+  <div></div>
+</ValidationObserver>
+```
+
 ## Scoped Slot Data
 
 The scoped slot is passed an object containing a flags object representing the merged state of all providers registered under the observer. It contains the following properties:
@@ -210,9 +235,10 @@ Below is the reference of the ValidationObserver public API.
 
 ### Props
 
-|Prop  | Type     | Default Value         | Description                                                           |
-|------|----------|-----------------------|-----------------------------------------------------------------------|
-| tag  | `string` | `span`                | The default tag to [render](#rendering).      |
+|Prop   | Type     | Default Value         | Description                                                      |
+|-------|----------|------------------|-----------------------------------------------------------------------|
+| tag  | `string`  | `span`     | The default tag to [render](#rendering).      |
+| slim | `boolean` | `false`    | If true, it will make the observer [renderless](#renderless), only rendering the HTML inside its slot. |
 
 ### Methods
 
diff --git a/docs/guide/components/validation-provider.md b/docs/guide/components/validation-provider.md
index c4677dcc..4b0d52cd 100644
--- a/docs/guide/components/validation-provider.md
+++ b/docs/guide/components/validation-provider.md
@@ -76,6 +76,39 @@ The default tag can be changed using the provider's `tag` prop.
   </div>
 ```
 
+### Renderless
+
+Sometimes it is unsuitable for a Provider component in principle to render anything extra, because of limitations in the Vue rendering engine we cannot have multiple root nodes which limits the design choice to move away from renderless at the moment, in Vue 3.x it this may change with fragments.
+
+In 2.2.10 a `slim` prop can be used to force the component to be renderless, by default it is set to `false`.
+
+```vue
+<ValidationProvider rules="required" v-slot="{ errors }">
+  <div>
+    <input v-model="value" type="text">
+    <span>{{ errors[0] }}</span>
+  </div>
+</ValidationProvider>
+
+<!-- HTML Output, NO OUTER SPANS -->
+<div>
+  <input type="text">
+  <span>ERROR_MSG_PLACEHOLDER</span>
+</div>
+```
+
+Note that **only the first child** will be rendered when `slim` is used, any other nodes will be dropped as you cannot have multiple root nodes in a renderless component. Be mindful of that when using the `slim` prop.
+
+```vue
+<ValidationProvider rules="required" v-slot="{ errors }">
+  <input v-model="value" type="text">
+  <span>{{ errors[0] }}</span>
+</ValidationProvider>
+
+<!-- Only input is rendered, the span is dropped -->
+<input type="text">
+```
+
 ## Scoped Slot Data
 
 The object passed down to the slot scope is called the __validation context__. It has the following properties:
@@ -382,6 +415,7 @@ All the following props are optional.
 | debounce  | `number`  | `0`                   | Debounces the validation for the specified amount of milliseconds.           |
 | tag       | `string`  | `span`                | The default tag to [render](#rendering). |
 | persist   | `boolean` | `false`               | If true, the provider will keep its errors across mounted/destroyed lifecycles |
+| slim      | `boolean` | `false`               | If true, it will make the provider [renderless](#renderless), only rendering the HTML inside its slot. |
 
 ### Methods
 
diff --git a/src/components/observer.js b/src/components/observer.js
index 441c0af8..af4ca22e 100644
--- a/src/components/observer.js
+++ b/src/components/observer.js
@@ -1,4 +1,5 @@
 import { isCallable, values, findIndex, warn } from '../utils';
+import { createRenderless } from '../utils/vnode';
 
 const flagMergingStrategy = {
   pristine: 'every',
@@ -42,6 +43,10 @@ export const ValidationObserver = {
     tag: {
       type: String,
       default: 'span'
+    },
+    slim: {
+      type: Boolean,
+      default: false
     }
   },
   data: () => ({
@@ -114,16 +119,14 @@ export const ValidationObserver = {
     }
   },
   render (h) {
-    let slots = this.$scopedSlots.default;
-    this._persistedStore = this._persistedStore || {};
-    if (!isCallable(slots)) {
-      return h(this.tag, this.$slots.default);
+    let slots = this.$slots.default || this.$scopedSlots.default || [];
+    if (isCallable(slots)) {
+      slots = slots(this.ctx);
     }
 
-    return h(this.tag, {
-      on: this.$listeners,
-      attrs: this.$attrs
-    }, slots(this.ctx));
+    this._persistedStore = this._persistedStore || {};
+
+    return this.slim ? createRenderless(h, slots) : h(this.tag, { on: this.$listeners, attrs: this.$attrs }, slots);
   },
   methods: {
     subscribe (subscriber, kind = 'provider') {
diff --git a/src/components/provider.js b/src/components/provider.js
index 53564277..0948fe62 100644
--- a/src/components/provider.js
+++ b/src/components/provider.js
@@ -5,7 +5,7 @@ import Validator from '../core/validator';
 import RuleContainer from '../core/ruleContainer';
 import { normalizeEvents, isEvent } from '../utils/events';
 import { createFlags, normalizeRules, warn, isCallable, debounce, isNullOrUndefined, assign, isEqual, toArray } from '../utils';
-import { findModel, extractVNodes, addVNodeListener, getInputEventName } from '../utils/vnode';
+import { findModel, extractVNodes, addVNodeListener, getInputEventName, createRenderless } from '../utils/vnode';
 
 let $validator = null;
 
@@ -86,6 +86,10 @@ export const ValidationProvider = {
     tag: {
       type: String,
       default: 'span'
+    },
+    slim: {
+      type: Boolean,
+      default: false
     }
   },
   watch: {
@@ -178,7 +182,7 @@ export const ValidationProvider = {
       addListeners.call(this, input);
     });
 
-    return h(this.tag, nodes);
+    return this.slim ? createRenderless(h, nodes) : h(this.tag, nodes);
   },
   beforeDestroy () {
     // cleanup reference.
diff --git a/src/utils/vnode.js b/src/utils/vnode.js
index d34c32af..83b9083b 100644
--- a/src/utils/vnode.js
+++ b/src/utils/vnode.js
@@ -140,3 +140,18 @@ export function normalizeSlots (slots, ctx) {
     return arr.concat(slots[key]);
   }, []);
 };
+
+export function createRenderless (h, children) {
+  // Only render the first item of the node.
+  if (Array.isArray(children) && children[0]) {
+    return children[0];
+  }
+
+  // a single node.
+  if (children) {
+    return children;
+  }
+
+  // No slots, render nothing.
+  return h();
+};


Test patch:
diff --git a/tests/unit/component.js b/tests/unit/component.js
index e3d303e7..da1580f3 100644
--- a/tests/unit/component.js
+++ b/tests/unit/component.js
@@ -25,6 +25,19 @@ describe('Validation Provider Component', () => {
     expect(wrapper.html()).toBe(`<span><input type="text"></span>`);
   });
 
+  test('can be renderless with slim prop', () => {
+    const wrapper = mount({
+      data: () => ({ val: '' }),
+      template: `
+        <ValidationProvider v-slot="ctx" slim>
+          <input v-model="val" type="text">
+        </ValidationProvider>
+      `
+    }, { localVue: Vue });
+
+    expect(wrapper.html()).toBe(`<input type="text">`);
+  });
+
   test('SSR: render single root slot', () => {
     const output = renderToString({
       template: `
@@ -73,7 +86,7 @@ describe('Validation Provider Component', () => {
     expect(error.text()).toBe('');
   });
 
-  test('uses appropiate events for different input types', async () => {
+  test('uses appropriate events for different input types', async () => {
     const wrapper = mount({
       data: () => ({
         value: ''
@@ -590,6 +603,18 @@ describe('Validation Observer Component', () => {
     expect(wrapper.html()).toBe(`<form></form>`);
   });
 
+  test('can be renderless with slim prop', () => {
+    const wrapper = mount({
+      template: `
+        <ValidationObserver v-slot="ctx" slim>
+          <form></form>
+        </ValidationObserver>
+      `
+    }, { localVue: Vue });
+
+    expect(wrapper.html()).toBe(`<form></form>`);
+  });
+
   test('renders the scoped slot in SSR', () => {
     const output = renderToString({
       template: `