-
-
Save marina-mosti/99b08783b161fa4ba21ebd2ec664fa14 to your computer and use it in GitHub Desktop.
import { ref, computed, onBeforeUpdate, Comment, Fragment, Text, useSlots } from 'vue' | |
// Adapted from https://github.com/vuejs/vue-next/blob/ca17162e377e0a0bf3fae9d92d0fdcb32084a9fe/packages/runtime-core/src/helpers/renderSlot.ts#L77 | |
// Demo: https://play.vuejs.org/#eNqVVm1P3EYQ/iuDU+UOdNiEa0NBHE2gRE1bJVWg/RJH1Z49PhvstbW7PkDX+++d2bV9vmCIggTa3Xl7ZuaZMSvvbVX5yxq9E+9URyqrDGg0dXUWyqyoSmXgoiwqSFRZwMgP+MLqo068AoXJBEp5jkmp8O8qFgYnUGu8ykujYd3YOqNQRqXUBgq9gBlbjke/ZTuj3Y3ElItFbm1JYbwLszMKEUpgE38p8hrpfXP+BWSd53AC1k8o16E8DVwilAJdDBZVTpDoBnDK+O0JYLWyKNZk4R46VXghRYFnaXYabFvb9K1busxrY0oJb6I8i25nobcBHnpnF/wIBZ4GTo2Mes68iWc0pZtkC/9Gl5KKv2KfoRdRgCxH9bEyGZUj9E7ASlgm8ry8+92+GVXjpH2PUoxuB95v9D2/hd5fCjWqJYZeJzNCLZCAsvjy6gPe07kTFmVc56T9jPAT6jKvGaNTO69lTLB7ehbte8uRTC6u9eW9QanbpBgoa66tfugRO7i2T6W+gTv1p9aOOk1VbOnI7N1ulWYCcRu5M6jJFoKehM9b3aXrMP2/j9+N0RX7uCwq88BKF9ygboQGZL3BYHSaGN76H9NoOMkKCK0z0hOIMckkxi2G2VDIsXVmZ2sb/riZK64HOy9z9PNyMXbl3oRxM7YdzL2xJjle0283btSPARBEcOrO88WkNhYozQTeKbFwp2ti3aPdEeztUeA9+BUNqoIgabhL0aSoQNjKQaYdfCD38A/th+kJpMZU+iQIFplJ67lPIxaQwxvNf/clxQkyrWvUwfTgp9fW/5tKKFHA6l0tIybj2jnfh+sU3fEH/uszv5yBosSV1LA6L6mYgtcQ7AUWcwBvY1EZjF0y30Yzz8t5EIlXR69eH+L06AgPxME8mSYCj+Pjw/ggiaP59PDg5x/FcYJBJaJbsSD0qpYmK3A/osIGWkVBinmFigQoY1TcEN/oF38eHTGspEkNlh/KGN9r2zQYLyXd9K6jhksK3JuPS1QPYz533AHIErBPvnmo6H02a3u521q3kz6ka5v88iXs2OcozfKYwPpGZcV49wkP7gSw7aklDnlrFfqJubhtACItKzRZ8s+jSN1HoZEkItduYTHr+1z81HSebRlgQ8RUaILI00VrzwyyCv6Dj/MbjExDL+EI6y5df4jHTnDoBKU1eZ51eG+nze2NrOntzNm3veNa7vDLV3VmGXGWxHcItHTbhEwq7HhJi7SFNyGtkaLEiSYWo5AxqSI/6Tb7CegSMjNyxnY+WwTcwDJxEXaoj6PW8aiD1ZS+T8h+Z9mUyNJ0pcmcFpao814Vm1W4WXkNDrc/3X8iK+5tK93ar07++YtdpRuNZgmxzh/4wGq9vQoDW5M0XMf9W9Lv1nNHN+fYfs+7JTrg9qu4/IlwImf/r8S7KyNMzaYr5sQgGJ9W5KWIUguD/PRCQM/H50b8hZw1VHLAO0EzTly94Q8IWW789dJ9+qNEqWwXgXPchBgQ9ujReOh9LR+XoP0fIpTe+n9hfcRC | |
function vNodeIsEmpty (vnodes) { | |
return vnodes.every(node => { | |
if (node.type === Comment) return true | |
if (node.type === Text && !node.children.trim()) return true | |
if ( | |
node.type === Fragment && | |
vNodeIsEmpty(node.children) | |
) { | |
return true | |
} | |
return false | |
}) | |
} | |
/** | |
* Returns true if a slot has no content | |
* @param {Function | Object} slot a Vue 3 slot function or a Vue 2 slot object | |
* @returns {Boolean} | |
*/ | |
export const isEmpty = (slot, props) => { | |
if (!slot) return true | |
// if we get a slot that is not a function, we're in vue 2 and there is content, so it's not empty | |
if (typeof slot !== 'function') return false | |
return vNodeIsEmpty(slot(props)) | |
} | |
/** | |
* @param {String} [slotName=default] Optional string to check for a specific slot's status with the slotIsEmpty convinience computed | |
* @param {Object} [scopedSlotInjection={}] Object key/value collection of data to be injected to scoped slots | |
* @returns | |
*/ | |
export default function (slotName = 'default', scopedSlotInjection = {}) { | |
const emptySlots = ref({}) | |
const definedSlots = ref([]) | |
const checkEmptySlots = () => { | |
const slots = useSlots() | |
console.log(slots) | |
definedSlots.value = Object.keys(slots) | |
const _newStatus = {} | |
definedSlots.value.forEach(slotKey => { | |
_newStatus[slotKey] = isEmpty(slots[slotKey], scopedSlotInjection[slotKey]) | |
}) | |
emptySlots.value = _newStatus | |
} | |
onBeforeUpdate(() => { | |
checkEmptySlots() | |
}) | |
checkEmptySlots() | |
/** Single slot data - Requires `slotName` */ | |
const slotIsEmpty = computed(() => { | |
if (!slotName) return undefined | |
return !definedSlots.value.includes(slotName) || emptySlots.value[slotName] | |
}) | |
return { | |
emptySlots, | |
definedSlots, | |
slotIsEmpty | |
} | |
} |
import SetupEmptySlotCheck from './SetupEmptySlotCheck' | |
import { mount } from '@vue/test-utils' | |
import { ref } from 'vue' | |
const ComponentWithSlots = { | |
name: 'ComponentWithSlots', | |
template: ` | |
<div> | |
Default: | |
<slot /> | |
Named: | |
<slot name="namedSlot" /> | |
Empty: | |
<slot name="leaveEmpty" /> | |
Empty string: | |
<slot name="emptyString" /> | |
Comment: | |
<slot name="comment" /> | |
Fragment: | |
<slot name="fragment" /> | |
<p v-if="!definedSlots.includes('default') || emptySlots.default">Default is empty</p> | |
<p v-if="slotIsEmpty">Named is empty</p> | |
</div> | |
`, | |
setup (_, { slots }) { | |
// Multi slot check | |
const { emptySlots, definedSlots } = SetupEmptySlotCheck(slots) | |
// Single slot check | |
const { slotIsEmpty } = SetupEmptySlotCheck(slots, 'namedSlot') | |
return { | |
emptySlots, | |
definedSlots, | |
slotIsEmpty | |
} | |
} | |
} | |
function factory (opts = {}) { | |
return mount({ | |
name: 'ComponentUsingSlots', | |
components: { ComponentWithSlots }, | |
template: opts.template, | |
setup: opts.setup ? opts.setup : () => ({}) | |
}) | |
} | |
let wrapper | |
describe('SetupEmptySlotCheck', () => { | |
beforeEach(() => { | |
jest.clearAllMocks() | |
}) | |
afterEach(() => { | |
if (wrapper) wrapper.unmount() | |
}) | |
describe('identifying empty slots', () => { | |
describe('default slots', () => { | |
it('marks the default slot as empty when it has no content', async () => { | |
const template = ` | |
<ComponentWithSlots> | |
<template #default></template> | |
</ComponentWithSlots> | |
` | |
wrapper = factory({ template }) | |
await wrapper.vm.$nextTick() | |
expect(wrapper.text()).toContain('Default is empty') | |
}) | |
it('marks the default slot as not empty when it has content', async () => { | |
const template = ` | |
<ComponentWithSlots> | |
<template #default> | |
Some content! | |
</template> | |
</ComponentWithSlots> | |
` | |
wrapper = factory({ template }) | |
await wrapper.vm.$nextTick() | |
expect(wrapper.text()).not.toContain('Default is empty') | |
}) | |
}) | |
describe('named slots', () => { | |
it('marks a named slot as empty when it has no content', async () => { | |
const template = ` | |
<ComponentWithSlots> | |
<template #namedSlot></template> | |
</ComponentWithSlots> | |
` | |
wrapper = factory({ template }) | |
await wrapper.vm.$nextTick() | |
expect(wrapper.text()).toContain('Named is empty') | |
}) | |
it('marks a named slot as not empty when it has content', async () => { | |
const template = ` | |
<ComponentWithSlots> | |
<template #namedSlot> | |
Some named content! | |
</template> | |
</ComponentWithSlots> | |
` | |
wrapper = factory({ template }) | |
await wrapper.vm.$nextTick() | |
expect(wrapper.text()).not.toContain('Named is empty') | |
}) | |
}) | |
describe('other empty conditions', () => { | |
it('marks a slot as empty if the parent puts nothing in the slot', async () => { | |
const template = ` | |
<ComponentWithSlots /> | |
` | |
wrapper = factory({ template }) | |
await wrapper.vm.$nextTick() | |
expect(wrapper.text()).toContain('Named is empty') | |
}) | |
it('marks a slot as empty if the slot only contains whitespace', async () => { | |
const template = ` | |
<ComponentWithSlots> | |
<template #namedSlot> | |
</template> | |
</ComponentWithSlots> | |
` | |
wrapper = factory({ template }) | |
await wrapper.vm.$nextTick() | |
expect(wrapper.text()).toContain('Named is empty') | |
}) | |
it('marks a slot as empty if the slot only contains a comment', async () => { | |
const template = ` | |
<ComponentWithSlots> | |
<template #namedSlot> | |
<!-- Comment --> | |
</template> | |
</ComponentWithSlots> | |
` | |
wrapper = factory({ template }) | |
await wrapper.vm.$nextTick() | |
expect(wrapper.text()).toContain('Named is empty') | |
}) | |
it('marks a slot as empty if the slot only contains another empty slot', async () => { | |
const template = ` | |
<ComponentWithSlots> | |
<template #namedSlot> | |
<slot name="anotherSlot" /> | |
</template> | |
</ComponentWithSlots> | |
` | |
wrapper = factory({ template }) | |
await wrapper.vm.$nextTick() | |
expect(wrapper.text()).toContain('Named is empty') | |
}) | |
}) | |
}) | |
describe('handling changes to slot content', () => { | |
it('marks a slot as empty when it becomes empty', async () => { | |
const template = ` | |
<ComponentWithSlots> | |
<template #default> | |
<div v-if="showSlotContent">Slot content!</div> | |
</template> | |
</ComponentWithSlots> | |
` | |
const showSlotContent = ref(true) | |
wrapper = factory({ | |
template, | |
setup () { | |
return { showSlotContent } | |
} | |
}) | |
expect(wrapper.text()).not.toContain('Default is empty') | |
showSlotContent.value = false | |
await wrapper.vm.$nextTick() | |
expect(wrapper.text()).toContain('Default is empty') | |
}) | |
it('marks the slot as not empty when it gets content', async () => { | |
const template = ` | |
<ComponentWithSlots> | |
<template #default> | |
<div v-if="showSlotContent">Slot content!</div> | |
</template> | |
</ComponentWithSlots> | |
` | |
const showSlotContent = ref(false) | |
wrapper = factory({ | |
template, | |
setup () { | |
return { showSlotContent } | |
} | |
}) | |
expect(wrapper.text()).toContain('Default is empty') | |
showSlotContent.value = true | |
await wrapper.vm.$nextTick() | |
expect(wrapper.text()).not.toContain('Default is empty') | |
}) | |
}) | |
}) |
Hey @nicooprat yes, you have a good point. I'll have to rewrite it with an onBeforeUpdate
hook to account for this type of change
Many thanks for sharing this!
It still seems to miss reactivity though: if the slot content changes, the computed won't be triggered.
Did you find a way to fix this?
I'm currently migrating through Vue compat mode so I hope it's not related to this.
Give that a shot @nicooprat 👌
Thanks! Though I'm not sure to understand how it's meant to be used. For instance, if I try to display or not a div wrapping the default slot according to the slot emptiness, it doesn't seem to work (only once apparently): Vue SFC demo
I'll try to dig into your script if I can find some time 👍
I made a slightly different version, but I encounter a weird behavior: Vue SFC demo
It works great when using v-show
, but when using v-if
it stops working as soon as the slot is empty: clicking the button hides the slot, clicking again won't show it up again. It seems that onBeforeUpdate
is not triggered anymore.
It's hard to believe how difficult it is to reactively know if a slot has content in Vue 3...
Edit: some related issues:
Using vnodes.some(…)
and inverting the logic instead of every
might speed things up in case there are many vnodes.
I can't comment on 4733 because it's closed and locked, but I am realizing that Vue itself has logic to determine if it would show the default content passed to a slot doesn't it?
So if we treat it as wouldDefaultSlotContentRender
? this doesn't have to be treated as subjective, and the logic to determine this already mostly exists in Vue...
Many thanks for sharing this!
It still seems to miss reactivity though: if the slot content changes, the computed won't be triggered.
Did you find a way to fix this?
I'm currently migrating through Vue compat mode so I hope it's not related to this.