Vuejs: Using keep-alive (Or something similar) on a slot - vue.js

I've got a combination of tree hierarchy and tabs in vue. So far I've gotten the unfolding more or less working.
I need to remove things from the dom entirely when they're closed, because the data I'm talking about is large enough to bring a browser to its knees if it's all just left in the dom as display:none.
Take a look at this example:
Vue.component('tabs', {
template: '#tabs',
data(){
return {
tabs: [],
expanded:true,
defaultExpanded:true,
activeTab: null,
hasChildren:false,
};
},
methods: {
toggle() {
this.expanded = !this.expanded;
},
activate(tab) {
if (this.activeTab) {
this.activeTab.active = false;
}
tab.active = true;
this.activeTab = tab;
},
},
mounted(){
for (i = 0; i < this.$slots.default.length; i++) {
let t = this.$slots.default[i];
if (t.componentOptions && t.componentOptions.tag == 'tab') {
this.tabs.push(t.componentInstance);
}
}
if (this.tabs.length) {
this.activeTab = this.tabs[0];
this.activeTab.active = true;
}
this.expanded = this.defaultExpanded;
},
});
Vue.component('tab', {
template: '#tab',
data() {
return {
active: false,
};
},
props: ['label'],
});
app = new Vue({
'el': '#inst',
});
<!-- templates -->
<script type="text/x-template" id="tabs">
<div #click.stop="toggle">
<h1><slot name="h" /></h1>
<div v-show="expanded" class="children">
<ul><li v-for="tab in tabs" #click.stop="activate(tab)">{{tab.label}}</li></ul>
<div style="border:1px solid #F00"><slot /></div>
</div>
</script>
<script type="text/x-template" id="tab">
<strong v-show="active"><slot /></strong>
</script>
<!-- data -->
<tabs id="inst">
<div slot="h">Woot</div>
<tab label="label">
<tabs>
<div slot="h">Weet</div>
<tab label="sub">Weetley</tab>
</tabs>
</tab>
<tab label="label2">Woot3</tab>
</tabs>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.3.3/vue.min.js"></script>
This works fine, but if I change the v-show to v-if for performance, it loses state, tab buttons stop showing - basically lots of stuff breaks.
The problem is that as soon as I add v-if to the tab template's slot the entire component is removed when it's closed. This means the parent component's tabs list is a completely different bunch of objects than the ones that show up when it's opened a second time.
This means I can't click on a label to open a tab, since the tabs will be different instances by the time I get to them, and all the tabs will default to closed every time I close and open the parent.
What I really need is something like <keep-alive> - where I could tell vue to keep the components alive in memory without rendering them to the dom. But when I add that the entire thing stops working. It seems like it really doesn't work on slots, only on individual components.
So. tl;dr: How do I maintain the state of mixed trees and tabs while using v-if to keep the dom light?

Building on Bert Evans' codepen, I created a component that is just a slot. I made a keep-alive-wrapped dynamic component that is the slot-component when active and a blank component when not. Now there is no v-if and state is preserved in the children when you close and re-open the parent.
console.clear();
Vue.component('keepableSlot', {
template: '#keepable-slot'
});
Vue.component('tabs', {
template: '#tabs',
data() {
return {
tabs: [],
expanded: true,
activeTab: null,
};
},
methods: {
addTab(tab) {
this.tabs.push(tab)
},
toggle() {
this.expanded = !this.expanded;
},
activate(tab) {
if (this.activeTab) {
this.activeTab.active = false;
}
tab.active = true;
this.activeTab = tab;
},
},
watch: {
expanded(newValue) {
console.log(this.$el, "expanded=", newValue);
}
}
});
Vue.component('tab', {
props: ["label"],
template: '#tab',
data() {
return {
active: false
}
},
created() {
this.$parent.$parent.addTab(this)
}
});
app = new Vue({
'el': '#inst',
});
.clickable-tab {
background-color: cyan;
border-radius: 5px;
margin: 2px 0;
padding: 5px;
}
.toggler {
background-color: lightgray;
border-radius: 5px;
margin: 2px 0;
padding: 5px;
}
<script src="//cdnjs.cloudflare.com/ajax/libs/vue/2.3.3/vue.min.js"></script>
<script type="text/x-template" id="tabs">
<div>
<h1 class="toggler" #click.stop="toggle">
<slot name="h"></slot>
(expanded={{expanded}})
</h1>
<keep-alive>
<component :is="expanded && 'keepableSlot'">
<div class="children">
<ul>
<li class="clickable-tab" v-for="tab in tabs" #click.stop="activate(tab)">{{tab.label}}</li>
</ul>
<div>
<slot></slot>
</div>
</div>
</component>
</keep-alive>
</div>
</script>
<script type="text/x-template" id="keepable-slot">
<div>
<slot></slot>
</div>
</script>
<script type="text/x-template" id="tab">
<strong>
<component :is="active && 'keepableSlot'"><slot></slot></component>
</div>
</script>
<!-- data -->
<tabs id="inst">
<div slot="h">Woot</div>
<tab label="label">
<tabs>
<div slot="h">Weet</div>
<tab label="sub">Weetley</tab>
</tabs>
</tab>
<tab label="label2">Woot3</tab>
</tabs>

Related

How to give a dynamically rendered element it's own data value & target it in it's parent component?

I have a BaseMenuItem component that has normal button elements as well as special news elements.
I have added a ticker effect to the news type els and want to stop the ticker on that element when it's clicked. Currently the click event stops the ticker effect on the whole group.
How can I target a single element from that group?
There are two methods, openNews one showing the specific news article that the element is linked to.
And clearItemType that clears the itemType upon recieving the emitted event from the BaseMenuItem component.
I'm just not sure which element to target to change it's itemType.
Does Vuejs have a way to make an unique data value for dynamically generated elements?
If you need anymore information please let me know!
Cheers!
BaseMenuItem
<template>
<q-btn align="left" dense flat class="main-menu-item" v-on="$listeners">
<div class="flex no-wrap items-center full-width">
<iconz v-if="iconz" :name="iconz" type="pop" color="black" class="mr-md" />
<q-icon v-if="menuIcon" :name="menuIcon" class="text-black mr-md" />
<div #click="$emit('stop-ticker')" v-if="itemType === 'news'" class="ellipsis _ticker">
<div class="ellipsis _ticker-item">{{ title }}</div>
</div>
<div v-else>
<div class="ellipsis">{{ title }}</div>
</div>
<slot>
<div class="ml-auto"></div>
<div class="_subtitle mr-md" v-if="subtitle">{{ subtitle }}</div>
<q-icon name="keyboard_arrow_right" class="_right-side" />
<ComingSoon v-if="comingSoonShow" />
</slot>
</div>
</q-btn>
</template>
<style lang="sass" scoped>
// $
.main-menu-item
display: block
font-size: 15px
position: relative
width: 100%
border-bottom: 1px solid #F5F5F5
+py(10px)
._left-side
color: #000000
._subtitle
margin-left: auto
opacity: 0.7
._ticker
position: absolute
font-weight: bold
margin-left: 2em
width: 82%
&-item
display: inline-block
padding-left: 100%
animation: ticker 8s linear infinite
#keyframes ticker
to
transform: translateX(-100%)
</style>
<script>
import { iconz } from 'vue-iconz'
export default {
name: 'MainMenuItem',
components: { iconz },
props: {
comingSoonShow: { type: Boolean, default: false },
title: { type: String, default: 'menu' },
subtitle: { type: String, default: '' },
menuIcon: { type: String, default: '' },
iconz: { type: String, default: '' },
itemType: { type: String, default: '' },
}
}
</script>
MainMenuPage
<template>
<div class="eachMenuGroup" v-if="newsList.length">
<MainMenuItem
v-for="news in newsList"
:key="news.id"
#click="openNews(news)"
:title="news.title"
:itemType="itemType"
:class="{ readLink: readNewsList[news.id] }"
menuIcon="contactless"
#stop-ticker="clearItemType"
></MainMenuItem>
</div>
</template>
<style lang="sass" scoped>
.readLink
font-weight: 500
</style>
<script>
methods: {
openNews(postInfo) {
dbAuthUser().merge({ seenNewsPosts: { [postInfo.id]: true } })
Browser.open({ url: postInfo.url, presentationStyle: 'popover' })
},
clearItemType() {
this.itemType = ''
return
},
</script>
EDIT: I edited since your latest comment. See below
To target the exact element within your v-for that fired the event, you can use $refs by using an index :
<MainMenuItem v-for="(item, index) in items" :ref="`menuItem--${index}`" #stop-ticker="clearItemType(index)" />
In your method:
clearItemType(index){
console.log(this.$refs[`menuItem--${index}`])
// this is the DOM el that fired the event
}
Edited version after your comments:
If you pass the exact same prop to each el of your v-for you wont be able to modify its value just for one child in the parent context.
Instead, keep an array of distinctive values in your parent. Each child will receive one specific prop that you can change accordingly in the parent either by listening to an child emitted event or by passing index as click event as I've done below for simplicity.
See snippet below
Vue.config.productionTip = false;
const Item = {
name: 'Item',
props: ['name', 'isActive'],
template: `<div>I am clicked {{ isActive }}!</div>`,
};
const App = new Vue({
el: '#root',
components: {
Item
},
data: {
items: ["item1", "item2"],
activeItemTypes: []
},
methods: {
toggle(index) {
this.activeItemTypes = this.activeItemTypes.includes(index) ? this.activeItemTypes.filter(i => i !== index) : [...this.activeItemTypes, index]
}
},
template: `
<div>
<Item v-for="(item, i) in items" :name="item" #click.native="toggle(i)" :isActive="activeItemTypes.includes(i)"/>
</div>
`,
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/vue.js"></script>
<div id="root"></div>

this.$children is behaving as if it were reactive

The official doc says the this.$children is not reactive,
The direct child components of the current instance. Note there’s no order guarantee for $children, and it is not reactive...
hence, any changes should not trigger any re-renders. [this.$children api is removed from vuejs v3, hence it works only in v2.x.]
I found this interesting... https://codepen.io/tatimblin/pen/oWKdjR
The code in sandbox above is a demonstration of tab UI implemented using slot & this.$childen api.
Initially tabs component is holding a reference to the this.$children Array, here's a log of that:
Interesting part is, the isActive prop of tab is being changed using that Array, yet it's reflected in each component, resulting in re-render..
I'm not sure what's happening here.. maybe I'm missing something.
template:
<div id="root" class="container">
<tabs>
<tab name="Services" :selected="true">
<h1>What we do</h1>
</tab>
<tab name="Pricing">
<h1>How much we do it for</h1>
</tab>
<tab name="About Us">
<h1>Why we do it</h1>
</tab>
</tabs>
</div>
JS:
Vue.component('tabs', {
template: `
<div>
<div class="tabs">
<ul>
<li v-for="tab in tabs" :class="{ 'is-active': tab.isActive }">
<a :href="tab.href" #click="selectTab(tab)">{{ tab.name }}</a>
</li>
</ul>
</div>
<div class="tabs-details">
<slot></slot>
</div>
</div>
`,
data() {
return {tabs: [] };
},
created() {
this.tabs = this.$children;
},
methods: {
selectTab(selectedTab) {
this.tabs.forEach(tab => {
tab.isActive = (tab.name == selectedTab.name);
// this is how the isActive prop is changed, using this.$children
});
}
}
});
Vue.component('tab', {
template: `
<div v-show="isActive"><slot></slot></div>
`,
props: {
name: { required: true },
selected: { default: false}
},
data() {
return {
isActive: false
};
},
computed: {
href() {
return '#' + this.name.toLowerCase().replace(/ /g, '-');
}
},
mounted() {
this.isActive = this.selected;
}
});
new Vue({
el: '#root'
});
The children are reactive because they are Vue components themselves, having the full power of reactivity already, apart from the tabs parent.
The doc quote means content that would otherwise not be reactive on its own. This distinction could maybe be a little clearer. On the other hand, tabs would not work properly if the slot content was anything other than a component with an isActive property, which forms a tight coupling. It wouldn't work properly with raw HTML slot content such as:
<tabs>
<div>
Hi, I'm not a component
</div>
</tabs>

How to use x-template to separate out template from Vue Component

Tried to separate out template from Vue Component as below but it does not work.
Referencing only vue.js file and not browsify.
Vue.component('my-checkbox', {
template: '#checkbox-template',
data() {
return { checked: false, title: 'Check me' }
},
methods: {
check() { this.checked = !this.checked; }
}
});
<script type="text/x-template" id="checkbox-template">
<div class="checkbox-wrapper" #click="check">
<div :class="{ checkbox: true, checked: checked }"></div>
<div class="title"></div>
</div>
</script>
Or any alternate way to separate out template from vue component.
You define X-Templates in your HTML file. See below for a brief demo
// this is the JS file, eg app.js
Vue.component('my-checkbox', {
template: '#checkbox-template',
data() {
return { checked: false, title: 'Check me' }
},
methods: {
check() { this.checked = !this.checked; }
}
});
new Vue({el:'#app'})
/* CSS file */
.checkbox-wrapper {
border: 1px solid;
display: flex;
}
.checkbox {
width: 50px;
height: 50px;
background: red;
}
.checkbox.checked {
background: green;
}
<!-- HTML file -->
<script src="https://cdn.jsdelivr.net/npm/vue#2.5.17/dist/vue.min.js"></script>
<script type="text/x-template" id="checkbox-template">
<div class="checkbox-wrapper" #click="check">
<div :class="{ checkbox: true, checked: checked }"></div>
<div class="title">{{ title }}</div>
</div>
</script>
<div id="app"> <!-- the root Vue element -->
<my-checkbox></my-checkbox> <!-- your component -->
</div>
Sorry for my bad english it's not my main language!
Try it!
You need generate two file in same directory:
path/to/checkboxComponent.vue
path/to/checkboxComponent.html
In checkboxComponent.vue file
<script>
// Add imports here eg:
// import Something from 'something';
export default {
template: require('./checkboxComponent.html'),
data() {
return { checked: false, title: 'Check me' }
},
methods: {
check() { this.checked = !this.checked; }
}
}
</script>
In checkboxComponent.html file
<template>
<div class="checkbox-wrapper" #click="check">
<div :class="{ checkbox: true, checked: checked }"></div>
<div class="title"></div>
</div>
</template>
Now you need to declare this Component in same file you declare Vue app, as the following:
Vue.component('my-checkbox', require('path/to/checkboxComponent.vue').default);
In my case
I have three files with these directories structure:
js/app.js
js/components/checkboxComponent.vue
js/components/checkboxComponent.html
In app.js, i'm declare the Vue app, so the require method path need to start with a dot, like this:
Vue.component('my-checkbox', require('./components/checkboxComponent.vue').default);

Vue Transition on Element When Route Changes

Context:
I have a navigation component which is present on every page but there is a logo element in that component which I am removing based on what route the user is at. I want to add a transition effect to this element when it disappears/appears and I have tried to do so using Vue transitions as you can see down below.
Problem is:
Element only fades in when this.header goes from false to true - whenever it goes from true to false no animation happens.
You can look at the problem for yourself in this code sandbox
Sidenote:
The CSS is not the problem. I know this because the desired effect works perfectly well if I instead trigger it using a button. The problem seems to have something to do with the nature of using a router change to trigger the animation. Do any of you have any idea why this would be the case?
<template>
<div class="headerNav">
<transition name="fade">
<div class="logo" v-if="!this.logo"></div>
</transition>
</div>
</template>
<script>
export default {
name: 'Navbar',
components: {
postFilter,
},
data() {
return {
logo: null,
}
},
mounted() {
//changing around the header depending on the page we are on so that we can use one header for all pages
if (this.$route.name == 'Library' || this.$route.name == 'Profile') {
this.logo = false
} else {
this.logo = true
}
}
</script>
The CSS (this should not be a problem but I included it anyway)
.fade-enter-active {
transition: all .3s ease;
}
.fade-leave-active {
transition: all .8s cubic-bezier(1.0, 0.5, 0.8, 1.0);
}
.fade-enter, .fade-leave-to {
transform: translateY(10px); opacity: 0;
}
Based on the comments and codesandboxes, here is a working version: https://codesandbox.io/s/olz3jrk829
Two main changes:
Instead of
<transition name="fade">
<div class="logo" v-if="page"></div>
</transition>
<transition name="fade">
<div class="logo two" v-if="!page"></div>
</transition>
Combine both div in one transition. Vue needs keys to determine each section:
<transition name="fade">
<div v-if="page" class="logo" key="1">12</div>
<div v-else class="logo two" key="2">34</div>
</transition>
Use the computed function instead of mounted:
computed: {
page() {
if (this.$route.name == 'otherpage') {
return false
} else {
return true
}
}
}
Most importantly, you reuse your navigation in each component (homeand otherpage in your example), so the leave transition doesn't get triggered from mounting.
The right way would be to remove the navigation component from the home and otherpage component, so it gets used only once in App.vue, which let all other components share one navigation instance.
Here is your original question with the changes:
<template>
<div class="headerNav">
<transition name="fade">
<div v-if="logo" class="logo" key="1"></div>
<div v-else class="logo two" key="2"></div>
</transition>
</div>
</template>
<script>
export default {
name: 'Navbar',
components: {
postFilter,
},
data() {
return {}
},
computed: {
logo() {
if (this.$route.name == 'Library' || this.$route.name == 'Profile') {
return false
} else {
return true
}
}
}
</script>

Move elements passed into a component using a slot

I'm just starting out with VueJS and I was trying to port over a simple jQuery read more plugin I had.
I've got everything working except I don't know how to get access to the contents of the slot. What I would like to do is move some elements passed into the slot to right above the div.readmore__wrapper.
Can this be done simply in the template, or am I going to have to do it some other way?
Here's my component so far...
<template>
<div class="readmore">
<!-- SOME ELEMENTS PASSED TO SLOT TO GO HERE! -->
<div class="readmore__wrapper" :class="{ 'active': open }">
<slot></slot>
</div>
Read {{ open ? lessLabel : moreLabel }}
</div>
</template>
<script>
export default {
name: 'read-more',
data() {
return {
open: false,
moreLabel: 'more',
lessLabel: 'less'
};
},
methods: {
toggle() {
this.open = !this.open;
}
},
}
</script>
You can certainly do what you describe. Manipulating the DOM in a component is typically done in the mounted hook. If you expect the content of the slot to be updated at some point, you might need to do the same thing in the updated hook, although in playing with it, simply having some interpolated content change didn't require it.
new Vue({
el: '#app',
components: {
readMore: {
template: '#read-more-template',
data() {
return {
open: false,
moreLabel: 'more',
lessLabel: 'less'
};
},
methods: {
toggle() {
this.open = !this.open;
}
},
mounted() {
const readmoreEl = this.$el.querySelector('.readmore__wrapper');
const firstEl = readmoreEl.querySelector('*');
this.$el.insertBefore(firstEl, readmoreEl);
}
}
}
});
.readmore__wrapper {
display: none;
}
.readmore__wrapper.active {
display: block;
}
<script src="//cdnjs.cloudflare.com/ajax/libs/vue/2.4.2/vue.min.js"></script>
<div id='app'>
Hi there.
<read-more>
<div>First div inside</div>
<div>Another div of content</div>
</read-more>
</div>
<template id="read-more-template">
<div class="readmore">
<!-- SOME ELEMENTS PASSED TO SLOT TO GO HERE! -->
<div class="readmore__wrapper" :class="{ 'active': open }">
<slot></slot>
</div>
Read {{ open ? lessLabel : moreLabel }}
</div>
</template>