Vue Composition API with "is" attribute for conditional rendering - vue.js

I'm working on an onboarding process that will collect a users name, location, job , etc. It needs to be one question per page but as an SPA so I currently have around 20 components to conditionally render.
Same problem as this but I've been asked change to Composition API and now I can't get this to work.
Vue - Render new component based on page count
The solution in the above was to make an array with all the page titles, a for loop and use :is to render each page as needed.
My components are named in this format: MLandingPage.vue, MFirstName.vue, etc.
I also have buttons that add or minus 1 from onboardingStep to go forward or back a step.
I have tried this:
const onboardingPages = ref(["MLandingPage", "MFirstName", 'MWelcome', 'MLastName', 'MAge' ]);
const onboardingStep = ref(0);
<template v-for="(onboardingPage, index) in onboardingPages" :key="index">
<component :is="onboardingPage" v-if="index === onboardingStep"/>
</template>
This doesn't render anything on the page and when I inspect, it just has <mlandingpage></mlandingpage> with no content.
I tried this instead:
const onboardingPages = ref(["m-landing-page", "m-first-name", 'm-welcome', 'm-last-name', 'm-age' ]);
Still nothing and I get this when I inspect the page: <m-landing-page></m-landing-page>
As a test, if I just write <m-landing-page></m-landing-page> in the code, it works.
Totally new to Composition API and the "is" attribute so any help would be great.
Edit - added more code for context:
<script setup>
import MLandingPage from "~~/components/onboarding/MLandingPage.vue";
import MFirstName from "~/components/onboarding/MFirstName.vue";
import MLastName from "~/components/onboarding/MLastName.vue";
import MAge from "~/components/onboarding/MAge.vue";
import { ref } from "vue";
const onboardingPages = ref(["m-landing-page", "m-first-name", 'm-welcome', 'm-last-name', 'm-age' ]);
const onboardingStep = ref(0);
function prevStep() {
onboardingStep.value -= 1;
}
function nextStep() {
onboardingStep.value += 1;
}
</script>
<template>
<div class="flex items-center justify-end gap-2 md:gap-10 mt-16 px-5 md:px-20">
<h1>Example Page Title</h1>
<div class="mt-16 p-5 pr-8 md:px-20 md:mt-24">
<template v-for="(onboardingPage, index) in onboardingPages" :key="index">
<component :is="onboardingPage" v-if="index === onboardingStep"/>
</template>
</div>
<button #click="prevStep">Back</button>
<button #click="nextStep">Next</button>
</div>
</template>
Thanks!

Found a much simpler way around this
Key issue was that I had the page names as strings
const onboardingPages = ref([MLandingPage, MFirstName, MWelcome, MLastName, MAge]);
<div class="mt-16 p-5 pr-8 md:px-20 md:mt-24">
<transition name="fade">
<component :is="onboardingPages[onboardingStep]" />
</transition>
</div>

Related

Nanostores in Astro is not working as expected with Vue... does not sync or hold state on page re-render

I am trying to create an online store with astro while using Vue componenents (SSR suing Netlify as an Adapter).
It's been a good learning experience and I am intentionally not using NUXT as I'd like to try out the SSR option of Astro with Vue Components. However if I feel this does not work out then I might finally decide on Nuxt. So far everything seems to be really nice with Astro and Vue.. but now I've run into a roadblock.
State Management
Astro recommends Nanostores as the state management option for Astro projects. https://docs.astro.build/en/core-concepts/sharing-state/
So I've tried it and it seemed to work well but now I am realising that when I change the page the state is lost. I am using SSR as the rendering option and using netlify as the adapter.
For example I have this in my cartStore.js
import { atom, map } from 'nanostores';
export const cartItems = map({});
export function addCartItem({ id, name, imageSrc }) {
const existingEntry = cartItems.get()[id];
if (existingEntry) {
cartItems.setKey(id, {
...existingEntry,
quantity: existingEntry.quantity + 1,
})
} else {
cartItems.setKey(
id,
{ id, name, imageSrc, quantity: 1 }
);
}
}
and this in my AddToCartButton.vue
<template>
<button
type="button"
class="flex max-w-xs flex-1 items-center justify-center rounded-md border border-transparent bg-orange-600 py-3 px-8 text-base font-medium text-white hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 focus:ring-offset-gray-50 sm:w-full"
#click="addToCart"
>
Add to bag
</button>
<pre>{{ $cartItems }}</pre>
</template>
<script setup>
import { addCartItem, cartItems } from "./../../store/cartStore";
import { useStore } from "#nanostores/vue";
const $cartItems = useStore(cartItems);
const addToCart = function () {
// Do something before adding to cart like open the cart slider
//
// Adding Item to Cart
addCartItem(props.productItem);
};
const props = defineProps({
productItem: {
type: Object,
},
});
</script>
Now when I use this, I expect my CartIcon in my header to update itself to the $cartItem.length based on the code below for my CartIcon.vue
<template>
<div class="ml-4 flow-root lg:ml-8">
<a href="/cart/123" class="group -m-2 flex items-center p-2">
<ShoppingBagIcon
class="h-6 w-6 flex-shrink-0 text-gray-400 group-hover:text-gray-500"
aria-hidden="true"
/>
<span
class="ml-2 text-sm font-medium text-gray-700 group-hover:text-gray-800"
>{{ $cartItems.length ? cartItems.length : 0 }}</span
>
<span class="sr-only">items in cart, view bag</span>
</a>
</div>
</template>
<script setup>
import {
ShoppingBagIcon,
} from "#heroicons/vue/24/outline";
import { cartItems } from "./../../store/cartStore";
import { useStore } from "#nanostores/vue";
const $cartItems = useStore(cartItems);
</script>
but for some reason it doesn't seem to be in sync.. I tried to view the cartItems value using <pre>{{$cartItems}}</pre> and realised that the value of cartItems is not being stored.. and I can't figure out what I am doing wrong..
Please note that I am importing separate vue compoments into astro.. and the header and footer are part of all the pages in astro.. and the cartIcon is in the header.. so in some ways it's always part of the page (just mentioning this if there's a suggestion to ensure that the components exist on the same page in astro).. Frankly the point of a state management option would be to ensure that the state is maintained irrespective of whether the components are on the same page in astro.
However I am not very experienced with SSR and I'd like to understand if there's some differences that I should expect with state management in an SPA and in an SSR..
Can someone help me out on this?
Stores are just object stored clientside, if you need to make them persistent you can use the nanostores persistent library

Vue3, Render parent div only if slot inside of it has content

I'm trying to render a header only if there's a text or a populated slot inside of it.
I tried:
<div
class="flex py-sm px-md w-full align-middle rounded-t-xl"
v-if="props.title || $slots.header"
:class="[`bg-${props.headerColor}`]"
>
<p class="text-bo-xl font-bold" :class="`text-${props.titleTextColor}`">
{{ props.title }}
</p>
<slot name="header"></slot>
</div>
But the div renders anyway, even if the slot is empty. I think it considers the slot present even if it's not populated.
Any ideas?
sorry for the late response. If I utilize computed() it seems to work:
<template>
<section>
<h4>lorem</h4>
<div v-if="hasHeaderSlot">
<h2>ipsum</h2>
<slot name="header"></slot>
</div>
</section>
</template>
<script>
import { computed } from 'vue';
export default {
setup(_, { slots }) {
const hasHeaderSlot = computed(() => slots.header && slots.header());
return {
hasHeaderSlot,
};
},
};
</script>
I hope this helps. With best regards

Vue - display/create component from a function

One thing that I have been struggling to figure out how to do better is modals. Currently, I am registering the modal component on each Vue that needs it. However, this feels rather sloppy, as I am having to register the component several times. Even using mix-ins just does not feel like an elegant solution. What would be optimal to be able to do is to mimic JavaScript's alert() method on the Vue instance. For example, be able to call this.ShowModal(Header, Body)
However, from my understanding, there is no way to accomplish this
Take for example my Modal example. You could have a modal template like this:
<script type="text/x-template" id="modal-template">
<transition name="modal">
<div class="modal-mask">
<div class="modal-wrapper">
<div class="modal-container">
<div class="modal-header">
<slot name="header">
default header
</slot>
</div>
<div class="modal-body">
<slot>
</slot>
</div>
<div class="modal-footer">
<slot name="footer">
default footer
<button class="modal-default-button" #click="$emit('close')">
OK
</button>
</slot>
</div>
</div>
</div>
</div>
</transition>
</script>
Then you would have to reference the component over and over again like this
<template>
<button #click="displayModal">Display the Modal Alert</button>
<modal v-if="showModal" #close="showModal = false">
<h3 slot="header"> This is a good header </h3>
<p>
Look at me I am the body! You have seen me {{displayCount}} times!
</p>
</modal>
</template>
<script>
components: {modal},
data: {
showModal: false,
displayCount: 0
},
methods: {
displayModal(){
this.displayCount++
this.showModal = true;
}
}
</script>
If you wanted to reuse the component for several messages from within the parent you would then have to add several more variables to store things such as the header and body. You could put some of the logic into a mixin but you would still have to have the clutter of adding the modal component and possibly the mixin.
This brings us to the question. Is there a way to create a function in the Vue instance that would allow for us to dynamically create a Modal component and fill in the slots with arguments passed to the function? e.g. this.ShowModal("This is a good header", "Look at me I am the body!")
Use Vue.extend() create a "modal" constructor and create a instance,you can mount it to DOM dynamically by $mount or other ways
In Modal example:
modal.vue:
<template>
<div>
{{message}} //your modal content
</div>
</template>
<script>
export default {
name: 'modal',
data(){
return {
message: '',
}
},
methods:{
/************/
close () {
/****this.$destroy()****/
}
}
}
</script>
modal.js:
import myModal from 'modal.vue'
import Vue from 'vue'
const modalConstructor = Vue.extend(myModal)
const modal = (options,DOM)=>{
const modalInstance = new modalConstructor({data:options})
modalInstance.vm = modalInstance.$mount() //get vm
const dom = DOM || document.body // default DOM is body
dom.appendChild(modalInstance.vm.$el) // mount to DOM
return modalInstance.vm
}
export default modal
now you can create a Modal component by a function like this:
import showModal from 'modal.js'
showModal({message:"..."})

Vue $emit and #click handler behaving differently on Firefox

New to Vue and wrapping my head around the main concepts.
I currently have two separate components BodyHero.vue and SelectedHero.vue that I need to pass data from BodyHero.vue to SelectedHero.vue. Since there isn't a parent-child relationship between them I've set up a simple EventBus.
import Vue from 'vue'
export const EventBus = new Vue()
I've set up a simple emitter to pass information from BodyHero.vue to SelectedHero.vueupon click on router-link. (I'm using vue-router to route pages and the routes work appropriately - upon click the user is taken from BodyHero.vue to SelectedHero.vue.)
This is a portion of BodyHero.vue:
<template>
<div class="columns" style="margin: 0px 10px">
<div v-for="cryptoCurrency in firstFiveCryptoCurrencies" class="column">
<router-link to="/selected" #click.native="selectCryptoCurrency(cryptoCurrency)">
<div class="card">
<div class="card-image">
<figure class="image is-4by3">
<img :src="`/static/${cryptoCurrency.id}_logo.png`">
</figure>
</div>
<div class="card-content">
<p class="title is-5">{{ cryptoCurrency.name }}</p>
</div>
</div>
</router-link>
</div>
</div>
</template>
<script>
import { EventBus } from '../../event-bus.js'
const cryptoCurrencyData = require('../../cryptocurrency-data.json')
export default {
props: {},
name: 'bodyHero',
selectCryptoCurrency (cryptoCurrency) {
EventBus.$emit('cryptoCurrencySelected', cryptoCurrency)
}
And in SelectedHero.vue, upon created I listen for the emit.
created () {
EventBus.$on('cryptoCurrencySelected', cryptoCurrency => {
...
})
}
Now for the weird part. Chrome/Safari everything seems to work fine - the user is routed to the next component, the emit is successfully called and the cryptoCurrency object is appropriately passed.
For Firefox however, it appears that the EventBus.$emit('cryptoCurrencySelected', cryptoCurrency) doesn't seem to fire upon click (#click.native). So the user is directed to the next screen - and the object is empty. Not sure what's really the issue here or if I'm missing something. Would be happy to hear other opinions!

Only show slot if it has content

Is there a way to only display a slot if it has any content?
For example, I'm building a simple Card.vue component, and I only want the footer displayed if the footer slot has content:
Template
<template>
<div class="panel" :class="panelType">
<div class="panel-heading">
<h3 class="panel-title">
<slot name="title">
Default Title
</slot>
</h3>
</div>
<div class="panel-body">
<slot name="body"></slot>
<p class="category">
<slot name="category"></slot>
</p>
</div>
<div class="panel-footer" v-if="hasFooterSlot">
<slot name="footer"></slot>
</div>
</div>
</template>
Script
<script>
export default {
props: {
active: true,
type: {
type: String,
default: 'default',
},
},
computed: {
panelType() {
return `panel-${this.type}`;
},
hasFooterSlot() {
return this.$slots['footer']
}
}
}
</script>
In in View:
<card type="success"></card>
Since the above component doesn't contain a footer, it should not be rendered, but it is.
I've tried using this.$slots['footer'], but this returns undefined.
Does anyone have any tips?
It should be available at
this.$slots.footer
So, this should work.
hasFooterSlot() {
return !!this.$slots.footer;
}
Example.
You should check vm.$slots and also vm.$scopedSlots for it.
hasSlot (name = 'default') {
return !!this.$slots[ name ] || !!this.$scopedSlots[ name ];
}
CSS simplifies this a lot. Just use the following code and voila!
.panel-footer:empty {
display: none;
}
This is the solution for Vue 3 composition API:
<template>
<div class="md:grid md:grid-cols-5 md:gap-6">
<!-- Here, you hide the wrapper if there is no used slot or empty -->
<div class="md:col-span-2" v-if="hasTitle">
<slot name="title"></slot>
</div>
<div class="mt-5 md:mt-0"
:class="{'md:col-span-3': hasTitle, 'md:col-span-5': !hasTitle}">
<div class="bg-white rounded-md shadow">
<div class="py-7">
<slot></slot>
</div>
</div>
</div>
</div>
</template>
<script>
import {ref} from "vue";
export default {
setup(props, {slots}) {
const hasTitle = ref(false)
// Check if the slot exists by name and has content.
// It returns an empty array if it's empty.
if (slots.title && slots.title().length) {
hasTitle.value = true
}
return {
hasTitle
}
}
}
</script>
Now, in Vue3 composition API , you can use useSlots.
<script setup>
import { useSlots } from 'vue'
const slots = useSlots()
</script>
<template>
<div v-if="slots.content" class="classname">
<slot name="content"></slot>
</div>
</template>
In short do this in inline:
<template lang="pug">
div
h2(v-if="$slots.title")
slot(name="title")
h3(v-if="$slots['sub-title']")
slot(name="sub-title")
</template>
I have ran into a similiar issue but across a wide code base and when creating atomic design structured components it can be tiring writing hasSlot() methods all the time and when it comes to TDD - its one more method to test... Saying that, you can always put the raw logic in a v-if but i have found that the template end up cluttered and harder to read on occasions especially for a new dev checking out the code structure.
I was tasked to find out a way of removing parent divs of slots when the slot isnt provided.
Issue:
<template>
<div>
<div class="hello">
<slot name="foo" />
</div>
<div class="world">
<slot name="bar" />
</div>
</div>
</template>
//instantiation
<my-component>
<span slot="foo">show me</span>
</my-component>
//renders
<div>
<div class="hello">
<span slot="foo">show me</span>
</div>
<div class="world"></div>
</div>
as you can see, the issue is that i have an almost 'trailing' div, that could provide styling issues when the component author decides there is no need for a bar slot.
ofcourse we could go <div v-if="$slots.bar">...</div> or <div v-if="hasBar()">...</div> etc but like i said - that can get tiresome and eventually end up harder to read.
Solution
My solution was to make a generic slot component that just rendered out a slot with a surrounding div...see below.
//slot component
<template>
<div v-if="!!$slots.default">
<slot />
</div>
</template>
//usage within <my-component/>
<template>
<div>
<slot-component class="hello">
<slot name="foo"/>
</slot-component>
<slot-component class="world">
<slot name="bar"/>
</slot-component>
</div>
</template>
//instantiation
<my-component>
<span slot="foo">show me</span>
</my-component>
//renders
<div>
<div class="hello">
<span>show me</span>
</div>
</div>
I came into use-case issues when trying this idea and sometimes it was my markup structure that needed to change for the benefit of this approach.
This approach reduces the need for small slot checks within each component template. i suppose you could see the component as a <conditional-div /> component...
It is also worth noting that applying attributes to the slot-component instantiation (<slot-component class="myClass" data-random="randomshjhsa" />) is fine as the attributes trickle into the containing div of the slot-component template.
Hope this helps.
UPDATE
I wrote a plugin for this so the need for importing the custom-slot component in each consumer component is not needed anymore and you will only have to write Vue.use(SlotPlugin) in your main.js instantiation. (see below)
const SLOT_COMPONENT = {
name: 'custom-slot',
template: `
<div v-if="$slots.default">
<slot />
</div>
`
}
const SLOT_PLUGIN = {
install (Vue) {
Vue.component(SLOT_COMPONENT.name, SLOT_COMPONENT)
}
}
export default SLOT_PLUGIN
//main.js
import SlotPlugin from 'path/to/plugin'
Vue.use(SlotPlugin)
//...rest of code
Initially I thought https://stackoverflow.com/a/50096300/752916 was working, but I had to expand on it a bit since $scopeSlots returns a function which is always truthy regardless of its return value. This is my solution, though I've come to the conclusion that the real answer to this question is "doing this is an antipattern and you should avoid it if possible". E.g. just make a separate footer component that could be slotted in.
Hacky solution
hasFooterSlot() {
const ss = this.$scopedSlots;
const footerNodes = ss && ss.footer && ss.footer();
return footerNodes && footerNodes.length;
}
Best Practice (helper component for footer)
const panelComponent = {
template: `
<div class="nice-panel">
<div class="nice-panel-content">
<!-- Slot for main content -->
<slot />
</div>
<!-- Slot for optional footer -->
<slot name="footer"></slot>
</div>
`
}
const footerComponent = {
template: `
<div class="nice-panel-footer">
<slot />
</div>
`
}
var app = new Vue({
el: '#app',
components: {
panelComponent,
footerComponent
},
data() {
return {
name: 'Vue'
}
}
})
.nice-panel {
max-width: 200px;
border: 1px solid lightgray;
}
.nice-panel-content {
padding: 30px;
}
.nice-panel-footer {
background-color: lightgray;
padding: 5px 30px;
text-align: center;
}
<script src="https://unpkg.com/vue#2.6.11/dist/vue.min.js"></script>
<div id="app">
<h1>Panel with footer</h1>
<panel-component>
lorem ipsum
<template #footer>
<footer-component> Some Footer Content</footer-component>
</template>
</panel-component>
<h1>Panel without footer</h1>
<panel-component>
lorem ipsum
</panel-component>
</div>
Hope I understand this right. Why not using a <template> tag, which is not rendered, if the slot is empty.
<slot name="foo"></slot>
Use it like this:
<template slot="foo">
...
</template>
For Vue 3:
Create an utility function
//utils.js
function isSlotHasContent(slotName, slots) {
return Boolean(!!slots[slotName] && slots[slotName]()[0].children.length > 0);
}
In your component:
<script setup>
import { isSlotHasContent } from 'path/to/utils.js';
const slots = useSlots();
// "computed" props has a better performance
const isFooSlotHasContent = computed(() => isSlotHasContent('foo', slots));
</script>
<template>
<div>
<div v-if="isFooSlotHasContent">
<slot name="foo" />
</div>
<div v-if="!isFooSlotHasContent">
Some placeholder
</div>
</div>
</template>
TESTED
So this work for me in vue 3:
I use onMounted to first get the value, and then onUpdate so the value can update.
<template>
<div v-if="content" class="w-1/2">
<slot name="content"></slot>
</div>
</template>
<script>
import { ref, onMounted, defineComponent, onUpdated } from "vue";
export default defineComponent({
setup(props, { slots }) {
const content = ref()
onMounted(() => {
if (slots.content && slots.content().length) {
content.value = true
}
})
onUpdated(() => {
content.value = slots.content().length
console.log('CHECK VALUE', content.value)
})
})
</script>
#Bert answer does not seem to work for dynamic templates like <template v-slot:foo="{data}"> ... </template>.
i ended up using:
return (
Boolean(this.$slots.foo) ||
Boolean(typeof this.$scopedSlots.foo == 'function')
);
I like the Solution of #AlexMA however in my case I needed to pass props to the function in order to get the nodes to show up.
Here is an example of how I am passing the "row" to the scoped slot, in my case the row contains a type param that I want to test against in the calling component.
<other-component>
<template v-slot:expand="{ row }" v-if="!survey.editable">
<div v-if="row.type != 1" class="flex">
{{ row }}
</div>
</template>
</other-component>
In "other-component" I have the template defined as
<template>
<div>
<div v-for="(row, index) in rows">
{{ hasSlotContent(row) }}
<slot name="expand" :row="row"> </slot>
</div>
</div>
</template>
Because the v-slot requires "row" to be passed to it I created a a method
methods:{
hasSlotContent(row){
const ss = this.$scopedSlots
const nodes = ss && ss.expand && ss.expand({ row: row })
return !!(nodes && nodes.length)
}
}
I call this on each iteration so that it can evaluate itself and give back the appropriate response.
you can use the "hasSlotContent(row)" method where-ever you need it, in my example I'm just outputting the truthy value to the DOM.
I hope this helps someone come to a quicker solution.
Reposting a Vue 3 solution from Github, which also works with Options API, since there was a fairly upvoted method from an Issue there:
The comment itself: https://github.com/vuejs/core/issues/4733#issuecomment-1024816095
The function (remove types if you're not writing TypeScript):
import {
Comment,
Text,
Slot,
VNode,
} from 'vue';
export function hasSlotContent(slot: Slot|undefined, slotProps = {}): boolean {
if (!slot) return false;
return slot(slotProps).some((vnode: VNode) => {
if (vnode.type === Comment) return false;
if (Array.isArray(vnode.children) && !vnode.children.length) return false;
return (
vnode.type !== Text
|| (typeof vnode.children === 'string' && vnode.children.trim() !== '')
);
});
}
This works just as fine, if you delete the slotProps argument (unless you need it).