I want to react to external slot changes. In actual HTMLSlotElements I have slotchange events for that but it seems like that's not how that works in Vue 3. How can I watch my slot and react to any new elements being slotted?
This is what I tried:
<script setup lang="ts">
import { useSlots } from 'vue';
const slots = useSlots();
function handleSlotChange() {
console.log('The slotted content has changed to ', slots.foo?.()[0]);
}
</script>
<template>
<div>
<slot name="foo" #slotchange="handleSlotChange"></slot>
</div>
</template>
The slot element doesn't accept events, but you could use #vnodeUpdated event in the element that wraps the slot to watch the changes :
<template>
<div #vnodeUpdated="handleSlotChange">
<slot name="foo"></slot>
</div>
</template>
the vnodeUpdated event handler has the current element as parameter which has dynamicChildren as property which refers to the elements passed as slots.
Or as #matthew-e-brown said in comments try to use the watch with slots :
import { useSlots,watch } from 'vue';
const slots = useSlots();
watch(()=>slots.foo(),(v)=>{
console.log('slots changed')
},{
deep:true
})
Since Vue's slots are not real slots we can't use Vue-internal tools to react to slot changes. Vue's compatibility layer prevents that. Instead, we can use a MutationObserver:
let observer: MutationObserver;
onMounted(() => {
const el = document.querySelector('el-to-observe');
const getSlotContent = () => el.querySelector('[slot]')?.tagName || 'Nothing';
const callback = () => console.log(getSlotContent() + ' was slotted');
observer = new MutationObserver(callback);
observer.observe(el, {
childList: true,
subtree: true,
});
});
onUnmounted(() => observer?.disconnect());
Related
I am trying run a function be it computed or watch or watcheffect after the template has mounted.
the watcheffect only executes once, computed of course runs before mounted.
I have tried flush: 'post' in watcheffect and flush in watch, I am rather stuck.
looking at the docs it should work as expected:
https://v3.vuejs.org/guide/composition-api-template-refs.html#watching-template-refs
Therefore, watchers that use template refs should be defined with the flush: 'post' option. This will run the effect after the DOM has been updated and ensure that the template ref stays in sync with the DOM and references the correct element.
app.vue
<template>
<div ref="target">
<h1>my title</h1>
<p>Tenetur libero aliquam at distinctio.</p>
<h1>hello</h1>
<p class="fuckyeah yolo">quia nam voluptatem illum ratione ipsum.</p>
<img src="img.jpg" />
" title="hello" alt />
<h2>hello</h2>
<ol>
<li>hello inital</li>
<li v-for="i in inc">hello</li>
</ol>
</div>
<div>
<button #click="inc++">inc</button>
</div>
<pre>
<code>
{{ toJson }}
</code>
</pre>
</template>
<script>
import { ref } from '#vue/reactivity'
import { templateRef } from '#vueuse/core'
import { useParser } from './markcomposable.js'
import { onMounted, computed, watchEffect } from '#vue/runtime-core';
export default {
setup() {
const inc = ref(0);
const target = ref(null);
const { toJson } = useParser(target);
return {
inc, target, toJson
}
}
}
</script>
//composable.js
import { parse, validate } from "fast-xml-parser"
import { ref, reactive, watchEffect, toRef, nextTick } from 'vue'
const useParser = (target) => {
const toJson = ref(null);
const jsonOptions = reactive({
//defaults
attributeNamePrefix: "",
ignoreAttributes: false,
textNodeName: "text",
arrayMode: true
})
const dumpJson = (target, options) =>
validate(target.outerHTML) ? parse(target.outerHTML, options) : isValid.value;
watchEffect(() => {
if (target.value) {
toJson.value = dumpJson(target.value, jsonOptions)
console.log(toJson.value)
}
}, {
flush: 'post',
})
return {
target,
toJson,
}
}
export { useParser }
If I understand correctly, you're trying to observe the outerHTML of the template ref, and you're expecting the template-ref watcher to be invoked whenever you insert nodes (via the button callback), but it's only ever invoked once.
This happens because the watcher effectively only watches the template ref and not its properties. The watcher would only be invoked when the template ref is initialized with the component/element reference. Template refs cannot be reassigned, so the watcher would not be invoked again. Moreover, the template ref's properties are not reactive, so the watcher would not be invoked if the target node's HTML changed.
Solution
Instead of the watcher, use a MutationObserver to observe the changes made to the target node, including the outerHTML.
Create a function that uses a MutationObserver to invoke a callback:
const observeMutations = (targetNode, callback) => {
const config = { attributes: true, childList: true, subtree: true }
const observer = new MutationObserver(callback)
observer.observe(targetNode, config)
return observer
}
In an onMounted hook, use that function to observe the template ref in target.value, passing a callback that sets toJson.value:
let observer = null
onMounted(() => {
toJson.value = dumpJson(target.value.outerHTML, jsonOptions)
observer = observeMutations(target.value, () => {
toJson.value = dumpJson(target.value.outerHTML, jsonOptions)
})
})
In an onUnmounted hook, disconnect the observer as cleanup:
onUnmounted(() => observer?.disconnect())
demo
Just to clarify:
You're trying to pass a template ref to your function but it always turns out as null when you execute the logic, right?
You could simply use a onMounted(() => {}) hook in your composable.js file or you could implement templateRef (which you already tried to include from the looks of it) and until(https://vueuse.org/shared/until/#usage).
So instead of const target = ref(null) you'll do const target = templateRef('target', null) and pass that to your composable.js.
There you'll watch until the ref is truthy.
So before your actual logic you'll do this:
await until(unrefElement(target)).toBeTruthy()
Afterwards the ref should provide an actual element (use unrefElement to get the element of the templateRef) and you can start applying your actual logic to it.
this is my component called Musics.vue:
<template>
<div class="flex flex-wrap mb-20 md:mb-32">
<div
v-for="(music, index) in musics"
:key="index"
class="w-full sm:w-6/12 lg:w-3/12 p-3"
>
<MusicCard :music="music" #play="setCurrent($event)" />
</div>
</div>
</template>
as you see MusicCard is in the loop. and each MusicCard emit play event to parent component. have can i write test for it? (i tried to use forEach but it failed)
this is my test:
it("commits a mutation when 'MusicCard' component emits play event", () => {
const components = wrapper.findAllComponents({ name: "MusicCard" });
expect(components.exists()).toBe(true);
});
thanks for your helping.
You probably need to decompose your test in several simple tests.
Assuming that you mount your component with either imported mutations or mocked mutations, you should be able to do something like:
// import { mutations } from "#/store/MyAppStore.js"
// or:
const mutations = {
myMutation: jest.fn()
}
const store = new Vuex.Store({ mutations })
const wrapper = mount(Musics, {
store, localVue
})
describe("When Musics component is mounted, it:", () => {
it("lists several music cards", () =>
{
const components = wrapper.findAllComponents({ name: "MusicCard" });
expect(components.length).toBeGreaterThan(1);
})
it("receive a play event from the 'MusicCard' components", () =>
{
// assert event has been emitted
expect(wrapper.emitted().myPlayEvent).toBeTruthy()
// assert event count
expect(wrapper.emitted().myPlayEvent.length).toBe(2)
// assert event payload
expect(wrapper.emitted().myPlayEvent[1]).toEqual([123])
})
it("commits a mutation when 'MusicCard' component emits play event", async () =>
{
wrapper.vm.$emit('myPlayEvent' /*, payload */)
await wrapper.vm.$nextTick()
expect(mutations.myMutation).toHaveBeenCalled()
// assert payload
expect(mutations.myMutation).toHaveBeenCalledWith(payload)
})
})
As the title of the question, this context is not available in the functional component. So if I have to emit an event, how can I do that?
For example in below code snippet:
<template functional>
<div>
<some-child #change="$emit('change')"></some-child>
</div>
</template>
My functional component doesn't have this context and hence $emit is not available. How can I bubble-up this event?
Child Component
<template functional>
<button #click="listeners['custom-event']('message from child')">
Button from child
</button>
</template>
Parent Component
<template>
<div>
<child-component #custom-event="call_a_method" />
</div>
</template>
See it in action on codesandbox
Do you want to emit the event from the vue instance?
export default {
functional: true,
render(createElement, { listeners }) {
return createElement(
"button",
{
on: {
click: event => {
const emit_event = listeners.event_from_child;
emit_event("Hello World!Is this the message we excpected? :/");
}
}
},
"Pass event to parent"
);
}
};
See it also a sandbox example here
This is explained in the docs Passing Attributes and Events to Child Elements/Components:
If you are using template-based functional components, you will also have to manually add attributes and listeners. Since we have access to the individual context contents, we can use data.attrs to pass along any HTML attributes and listeners (the alias for data.on) to pass along any event listeners.
At the most basic level, you can delegate all listeners like this:
<some-child v-on="listeners"></some-child>
If you only want to bind the change listener, you can do:
<some-child #change="listeners.change"></some-child>
but this will fail if listeners.change is undefined/null (not provided to the functional component).
If you need to handle the situation where there is no change listener, then you can do this:
<some-child #change="listeners.change && listeners.change($event)"></some-child>
otherwise you would have to settle by writing the render function by hand, since I don't think it is possible to conditionally assign the change listener to <some-child> in the template of a functional component. (Or maybe you can? I'm not sure.)
If you want to pass event listener conditionally you can do it inside functional component template like this:
v-on="listeners.change ? { change: listeners.change } : null"
The issue of conditionally attaching listeners is discussed here
a component with jsx:
export default {
name: "MyText",
functional: true,// functional component
props: {
value: {
type: [String, Number],
default: ""
}
},
render(h, context) {
const { props } = context;
// with jsx
// return (
// <button
// onClick={() => {
// console.log(context.listeners);
// context.listeners.input(Math.random().toString(36));
// context.listeners["my-change"](Math.random().toString(36));
// context.data.on.change(Math.random().toString(36));
// }}
// >
// {props.value}
// </button>
// );
// or use h function
return h(
"h1",
{
on: {
// emit some event when click h1
click: () => {
// has value prop has has input event auto
// event name come what event u listen in parent component
console.log(context.listeners);
context.listeners.input(Math.random().toString(36));
context.listeners["my-change"](Math.random().toString(36));
context.data.on.change(Math.random().toString(36));
}
}
},
props.value
);
}
};
conext.listeners is just an alias for context.data.on.
in parent componet, you should listen my-change and change, or has error.
event name inside component comes what event u listen in parent component
<MyText
v-model="value"
#change="change"
#my-change="myChange"
#u-change="uChange"
/>
vue 2.6.11 works well.
see the codesandbox online
Parent:
<Child #onFunction="handleFunction">
and this is the child component:
Child
<template functional>
<div>
<some-child #change="execute"></some-child>
</div>
</template>
methods:
execute(){
#emit("onFunction")
}
I'm new to vuejs and I'm trying to build a simple single file component for testing purpose.
This component simply displays a bool and a button that change the bool value.
It also listen for a "customEvent" that also changes the bool value
<template>
{{ mybool }}
<button v-on:click="test">test</button>
</template>
<script>
ipcRenderer.on('customEvent', () => {
console.log('event received');
this.mybool = !this.mybool;
});
export default {
data() {
return {
mybool: true,
};
},
methods: {
test: () => {
console.log(mybool);
mybool = !mybool;
},
},
};
</script>
The button works fine. when I click on it the value changes.
but when I receive my event, the 'event received' is displayed in the console but my bool doesn't change.
Is there a way to access the components data from my code?
Thanks and regards,
Eric
You can move ipcRenderer.on(...) into vuejs's lifecycle hooks like created.
See: https://v2.vuejs.org/v2/guide/instance.html#Instance-Lifecycle-Hooks
You are setting up the event listener outside of the component's options which you export by using
export default{ //... options }
Set up the event listener inside the vue options so the vue instance has control over it, in your case modifying dara property
As choasia suggested move the event listener to `created() life cycle hook:
<template>
{{ mybool }}
<button v-on:click="test">test</button>
</template>
<script>
export default {
data() {
return {
mybool: true,
};
},
methods: {
test: () => {
console.log(mybool);
mybool = !mybool;
},
},
created(){
ipcRenderer.on('customEvent', () => {
console.log('event received');
this.mybool = !this.mybool;
});
}
};
</script>
Now you component will starting listening for that particular even when the component is created
We have a component in Vue which is a frame, scaled to the window size, which contains (in a <slot>) an element (typically <img> or <canvas>) which it scales to fit the frame and enables pan and zoom on that element.
The component needs to react when the element changes. The only way we can see to do it is for the parent to prod the component when that happens, however it would be much nicer if the component could automatically detect when the <slot> element changes and react accordingly. Is there a way to do this?
To my knowledge, Vue does not provide a way to do this. However here are two approaches worth considering.
Watching the Slot's DOM for Changes
Use a MutationObserver to detect when the DOM in the <slot> changes. This requires no communication between components. Simply set up the observer during the mounted callback of your component.
Here's a snippet showing this approach in action:
Vue.component('container', {
template: '#container',
data: function() {
return { number: 0, observer: null }
},
mounted: function() {
// Create the observer (and what to do on changes...)
this.observer = new MutationObserver(function(mutations) {
this.number++;
}.bind(this));
// Setup the observer
this.observer.observe(
$(this.$el).find('.content')[0],
{ attributes: true, childList: true, characterData: true, subtree: true }
);
},
beforeDestroy: function() {
// Clean up
this.observer.disconnect();
}
});
var app = new Vue({
el: '#app',
data: { number: 0 },
mounted: function() {
//Update the element in the slot every second
setInterval(function(){ this.number++; }.bind(this), 1000);
}
});
.content, .container {
margin: 5px;
border: 1px solid black;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.13/vue.js"></script>
<template id="container">
<div class="container">
I am the container, and I have detected {{ number }} updates.
<div class="content"><slot></slot></div>
</div>
</template>
<div id="app">
<container>
I am the content, and I have been updated {{ number }} times.
</container>
</div>
Using Emit
If a Vue component is responsible for changing the slot, then it is best to emit an event when that change occurs. This allows any other component to respond to the emitted event if needed.
To do this, use an empty Vue instance as a global event bus. Any component can emit/listen to events on the event bus. In your case, the parent component could emit an "updated-content" event, and the child component could react to it.
Here is a simple example:
// Use an empty Vue instance as an event bus
var bus = new Vue()
Vue.component('container', {
template: '#container',
data: function() {
return { number: 0 }
},
methods: {
increment: function() { this.number++; }
},
created: function() {
// listen for the 'updated-content' event and react accordingly
bus.$on('updated-content', this.increment);
},
beforeDestroy: function() {
// Clean up
bus.$off('updated-content', this.increment);
}
});
var app = new Vue({
el: '#app',
data: { number: 0 },
mounted: function() {
//Update the element in the slot every second,
// and emit an "updated-content" event
setInterval(function(){
this.number++;
bus.$emit('updated-content');
}.bind(this), 1000);
}
});
.content, .container {
margin: 5px;
border: 1px solid black;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.13/vue.js"></script>
<template id="container">
<div class="container">
I am the container, and I have detected {{ number }} updates.
<div class="content">
<slot></slot>
</div>
</div>
</template>
<div id="app">
<container>
I am the content, and I have been updated {{ number }} times.
</container>
</div>
As far as I understand Vue 2+, a component should be re-rendered when the slot content changes. In my case I had an error-message component that should hide until it has some slot content to show. At first I had this method attached to v-if on my component's root element (a computed property won't work, Vue doesn't appear to have reactivity on this.$slots).
checkForSlotContent() {
let checkForContent = (hasContent, node) => {
return hasContent || node.tag || (node.text && node.text.trim());
}
return this.$slots.default && this.$slots.default.reduce(checkForContent, false);
},
This works well whenever 99% of changes happen in the slot, including any addition or removal of DOM elements. The only edge case was usage like this:
<error-message> {{someErrorStringVariable}} </error-message>
Only a text node is being updated here, and for reasons still unclear to me, my method wouldn't fire. I fixed this case by hooking into beforeUpdate() and created(), leaving me with this for a full solution:
<script>
export default {
data() {
return {
hasSlotContent: false,
}
},
methods: {
checkForSlotContent() {
let checkForContent = (hasContent, node) => {
return hasContent || node.tag || (node.text && node.text.trim());
}
return this.$slots.default && this.$slots.default.reduce(checkForContent, false);
},
},
beforeUpdate() {
this.hasSlotContent = this.checkForSlotContent();
},
created() {
this.hasSlotContent = this.checkForSlotContent();
}
};
</script>
There is another way to react on slot changes. I find it much cleaner to be honest in case it fits. Neither emit+event-bus nor mutation observing seems correct to me.
Take following scenario:
<some-component>{{someVariable}}</some-component>
In this case when someVariable changes some-component should react. What I'd do here is defining a :key on the component, which forces it to rerender whenever someVariable changes.
<some-component :key="someVariable">Some text {{someVariable}}</some-component>
Kind regard
Rozbeh Chiryai Sharahi
I would suggest you to consider this trick: https://codesandbox.io/s/1yn7nn72rl, that I used to watch changes and do anything with slot content.
The idea, inspired by how works VIcon component of vuetify, is to use a functional component in which we implement logic in its render function. A context object is passed as the second argument of the render function. In particular, the context object has a data property (in which you can find attributes, attrs), and a children property, corresponding to the slot (you could event call the context.slot() function with the same result).
Best regards
In Vue 3 with script setup syntax, I used the MutationObserver to great success:
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
const container = ref();
const mutationObserver = ref(null);
const mockData = ref([]);
const desiredFunc = () => {
console.log('children changed');
};
const connectMutationObserver = () => {
mutationObserver.value = new MutationObserver(desiredFunc);
mutationObserver.value.observe(container.value, {
attributes: true,
childList: true,
characterData: true,
subtree: true,
});
};
const disconnectMutationObserver = () => {
mutationObserver.value.disconnect();
};
onMounted(async () => {
connectMutationObserver();
setTimeout(() => { mockData.value = [1, 2, 3]; }, 5000);
});
onUnmounted(() => {
disconnectMutationObserver();
});
</script>
<template>
<div ref="container">
<div v-for="child in mockData" :key="child">
{{ child }}
</div>
</div>
</template>
My example code works better if the v-for is inside a slot that isn't visible to the component. If you are watching the list for changes, you can instead simply put a watcher on the list, such as:
watch(() => mockData.value, desiredFunc);
or if that doesn't work, you can use a deep watcher:
watch(() => mockData.value, desiredFunc, { deep: true });
My main goal is to highlight how to use the MutationObserver.
Read more here: https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver