Correct way to mutate data in slotted child component - vue.js

I want to create a table component made out of three parts.
The wrapper, the heads and the data.
While most of it works pretty well, I'm struggling with the order by functionality.
When the user clicks on a th tag, the data should be reordered and a little indicator should be shown.
The ordering works but the indicator doesn't.
The actual problem
I know that it's bad to mutate a property inside the child although it's defined in the parent. Since I use slots, I can't use the normal $emit.
Using the approach shown here brings me Uncaught (in promise) TypeError: parent is null and Unhandled error during execution of scheduler flush.
Although I already know that my current approach is wrong, I don't know how to do it right.
Googling around, I found keywords like writable computed props and scoped slots, but I can't put it together.
So what's the right way to realize two-way-binding in a slotted environment?
My Table.vue file
<template>
<div class="px-4 sm:px-6 lg:px-8">
<div class="mt-8 flex flex-col">
<div class="-my-2 -mx-4 overflow-x-auto sm:-mx-6 lg:-mx-8">
<Search v-if="searchable" :filters="props.filters"></Search>
<div class="inline-block min-w-full py-2 align-middle">
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table class="min-w-full divide-y divide-gray-300">
<thead class="bg-gray-50">
<tr>
<slot name="table-heads"></slot>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<slot name="table-body"></slot>
</tbody>
</table>
<Pagination v-if="paginationLinks" :links="paginationLinks"></Pagination>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import Pagination from "#/Components/Tables/Pagination.vue";
import {provide} from "vue";
import Search from "#/Components/Tables/Search.vue";
import {Inertia} from "#inertiajs/inertia";
let name = "Table";
let props = defineProps({
paginationLinks: Array,
dataUrl: String,
filters: Object,
order: Object,
searchable: Boolean
});
provide('dataUrl', props.dataUrl);
provide('order', props.order);
</script>
<style scoped>
</style>
My TableHead.vue file
<template>
<th #click="orderByClicked" scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6 cursor-pointer">
<div class="flex justify-between">
<slot></slot>
<span v-if="order.orderBy === props.orderKey">
<i v-if="order.orderDirection === 'asc'" class="fa-solid fa-chevron-up"></i>
<i v-if="order.orderDirection === 'desc'" class="fa-solid fa-chevron-down"></i>
</span>
</div>
</th>
</template>
<script setup>
import { inject } from "vue";
import { Inertia } from "#inertiajs/inertia";
let name = "TableHead";
let dataUrl = inject('dataUrl');
let order = inject('order');
let props = defineProps({
orderKey: String,
orderByClicked: Function
});
function orderByClicked() {
if (props.orderKey) {
if (order.orderBy === props.orderKey)
order.orderDirection = order.orderDirection === 'asc' ? 'desc' : 'asc';
else
order.orderDirection = "asc"
order.orderBy = props.orderKey;
Inertia.get(dataUrl, {orderBy: props.orderKey, orderDirection: order.orderDirection}, {
preserveState: true,
replace: true
});
}
}
</script>
<style scoped>
</style>
My TableData.vue file (just to be complete)
<template>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
<slot></slot>
</td>
</template>
<script setup>
let name = "TableData";
</script>
<style scoped>
</style>
putting it together
<Table :pagination-links="props.users.links" :data-url="'/users'" :searchable="true" :filters="props.filters" :order="props.order">
<template #table-heads>
<TableHead order-key="name">name</TableHead>
<TableHead order-key="email">email</TableHead>
<TableHead>Bearbeiten</TableHead>
</template>
<template #table-body>
<tr v-for="user in users.data" :key="user.id">
<TableData>{{ user.username }}</TableData>
<TableData>{{ user.email}}</TableData>
</tr>
</template>
</Table>

It is likely to be a reactivity problem.
Your indicators relies on the order property which you set at setup and seem to change correctly however I don't see anything to make it reactive for your template.
In Table.vue where you provide it, you might just need to import ref from Vue to make it reactive:
import { ref, provide } from 'vue'
// provide static value
provide('dataUrl', props.dataUrl);
// provide reactive value
const order = ref(props.order)
provide('order', order);

I realized that the error only occurred in combination with FontAwesome, so I looked further.
By inspecting the code, I found out that FontAwesome manipulates the DOM.
It should have been clear in hindsight but anyway...
The cause
The point is, that when you insert a tag like <i class="fa-solid fa-sort-up"></i> it's converted to <svg class="svg-inline--fa fa-sort-up" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="sort-up" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512" data-fa-i2svg=""><path fill="currentColor" d="M182.6 41.4c-12.5-12.5-32.8-12.5-45.3 0l-128 128c-9.2 9.2-11.9 22.9-6.9 34.9s16.6 19.8 29.6 19.8H288c12.9 0 24.6-7.8 29.6-19.8s2.2-25.7-6.9-34.9l-128-128z"></path></svg>
That's fine as long as you don't attach own attributes to the i tag because they get destroyed!
That happened to my v-if. Unfortunately it didn't silently vanish but it caused some weird errors.
The solution
Wrap any tags which are getting manipulated by a third party (in this case FontAwesome) within a suitable parent tag.
I came up with the following solution:
<span v-if="someCondition">
<i class="fa-solid fa-sort-up"></i>
</span>
Trivia
Interestingly this error didn't occur in https://sfc.vuejs.org for some reason. I couldn't reproduce it in any online tool. The components only crashed in my local dev environment.

Related

Vue 3 slots variable is empty for no reason

Using the setup syntax, I started a new projet week ago and when I try to use "slots" in the template or even a computed with useSlots. The slots variable is always empty even if the v-slots are working great (passing datas like I want).
<DatatableGeneric
title="Liste de clients (API)"
:headers="headers"
:data="apiResponse.results"
:loading="loading"
:serverItemsLength="apiResponse.count"
v-model:options="tableOptions"
#selected="updateSelected"
:show-select="showSelect"
sort-by="name"
sort-desc
must-sort
>
<template v-slot:test> TEST </template>
<template v-slot:item-actions="{ item }">
<slot name="item-actions" :item="item"></slot>
</template>
</DatatableGeneric>
In this code you can see that I have 2 named slots, "test" and "item-actions".
Child component:
slots? you here? {{ slots}}
slots? still not here? {{ testSlots }}
<slot name="test"> aaaaa </slot>
<tbody>
<tr v-for="item in computePaginatedData" :key="item.name">
<td
v-if="showSelect"
class="d-flex justify-center items-center align-center"
style="width: 100%"
>
<v-checkbox
:value="item"
color="primary"
v-model="computeSelected"
hide-details
class="align-center justify-center"
/>
</td>
<td v-for="header in headers" :key="header.value">
<slot :name="`item-${header.value}`" :item="item">
{{ get(item, header.value) }}
</slot>
</td>
</tr>
</tbody>
....
<script setup lang="ts">
import { ref, computed, reactive, onMounted, watch } from 'vue'
import { useSlots } from 'vue'
const slots = useSlots()
const testSlots = computed(() => {
return slots
})
.....
In the projet I see:
slots? you here? {} slots? still not here? {}
Something is wrong here in the projet but pretty sure it's not related to my code as the slots are working fine. They just aren't listed in the slots variable.
Also tried with "$slots" in the template
Well, it worked if I just loop on $slots with:
<template v-for "keyName in Object.keys($slots)" :key="kayName">
But on vue 2, I remember that when I print $slots I can see every slots inside.
Here in vue 3, I don't see anything.

How to pass images has props in a vue js component (Configured with vite cause require doesnt work in vite)

Here is my parent component
<template>
<rooms-card
roomImage="../../assets/images/room3.jpg"
roomType="Duplex Room"
roomDescription="Sami double bed 1 guest room 3 windows"
roomPrice="$50/night"
/>
</template>
Here is the child component
<template>
<div class="m-5 px-6 py-4 shadow-xl border mb-14">
<div class="w-64 h-72 bg-red-700">
<img :src="roomImage" class="h-full"/>
</div>
<div class="">
<h1 class="font-semibold text-xl my-3">{{roomType}}</h1>
<p class="text-sm my-2 text-slate-500 w-44">{{roomDescription}}</p>
<p>Starting from<span class="font-semibold text-xl">{{roomPrice}}</span></p>
<button class="bg-pink-500 my-5 px-6 py-3 text-white">
Book now
</button>
</div>
</div>
</template>
<script setup>
// import { any } from "prop-types";
const props = defineProps({
roomImage:String,
roomType: String,
roomDescription:String,
roomPrice:String
})
console.log(props.roomImage)
</script>
NOTE: even if i use require(#/asset/images......) in my image tag src it wont work because for some reasons vite isn't configure to use requires
Just import the asset in <script> and use the variable as the src attribute.
From the docs
Importing a static asset will return the resolved public URL when it is served:
<script setup>
import RoomImage from "../../assets/images/room3.jpg";
</script>
<template>
<rooms-card
:roomImage="RoomImage"
roomType="Duplex Room"
roomDescription="Sami double bed 1 guest room 3 windows"
roomPrice="$50/night"
/>
</template>

degree symbol not being rendered by vue component

I have a vue component which displays a gauge. I need to include units on the display and have this as one of the props of the component. However, because there are a number of gauges with different formatting it is all stored in a vuex store that reads its settings from an API. This all works nicely apart from when I want to bring special symbols (such as degree signs) across. The vuex object is storing the formatting object as:
{"min":0,"max":50,"dp":1,"units":"°C"}
and I use it in my component as follows:
<svg-gauge v-else
v-bind:g-value="device.value"
v-bind:g-min="device.format.min"
v-bind:g-max="device.format.max"
v-bind:g-decplace="device.format.dp"
v-bind:g-units="device.format.units"
>
The problem is that this simply displays °C rather than a degree symbol. If I hard code the last line as
g-units="°C"
It all works as expected. I suspect it is that I am having to use v-bind to pass the property and this is messing things up. Is there a way to ensure that v-bind is treating my characters as I would like?
EDIT: Here is the svg-gauge component template where the units are actually rendered.
<template>
<b-row>
<b-card no-body class="bg-dark text-light border-0" align="center">
<b-card-body class="m-0 pt-0 pb-0">
<h5><slot name="title">Title</slot></h5>
<div class="row mini-gauge pt-3" align="center">
<vue-svg-gauge
class="mini-gauge"
:start-angle="-90"
:end-angle="90"
:value="gValue"
:separator-step="0"
:min="gMin"
:max="gMax"
base-color="#595959"
:gauge-color="[{ offset: 0, color: '#347AB0'}, { offset: 100, color: '#D10404'}]"
:scale-interval="5"
>
<div style="line-height: 11rem">{{gValue.toFixed(gDecplace)}} {{gUnits}}</div>
</vue-svg-gauge>
</div>
<div class="row mini-gauge">
<div class="col" align="left">{{gMin}}</div>
<div class="col" align="right">{{gMax}}</div>
</div>
</b-card-body>
</b-card>
</b-row>
</template>
Change this line to have a span with a v-html. Then in the v-html pass the gUnits prop
<div style="line-height: 11rem">
{{gValue.toFixed(gDecplace)}}
<span v-html="gUnits"></span>
</div>
You can find the reason by looking here.
Hope this helps!

Collapsing vertical menu in element.io

I try to create a layout with element.io which has a collapsing sidebar menu.
It works quite well, the only problem is that I do not gain anything since the width of the menu and the content part are fixed.
My code looks like this:
<template>
<el-row>
<el-col :span="4">
<template>
<el-menu :router="true"
:default-active="$route.path"
background-color="#545c64"
text-color="#fff"
active-text-color="#ffd04b"
:collapse="isCollapse"
class="el-menu-vertical-demo"
>
<template v-for="rule in routes">
<el-menu-item :index="rule.path">
<i :class="rule.icon"></i>
<span slot="title">{{ rule.name }}</span>
</el-menu-item>
</template>
</el-menu>
</template>
</el-col>
<el-col :span="20">
<el-row>
<el-radio-group v-model="isCollapse" style="margin-bottom: 20px;">
<el-radio-button :label="true" :disabled="isCollapse" border>
<i class="fas fa-caret-left"></i>
</el-radio-button>
<el-radio-button :label="false" :disabled="!isCollapse" border>
<i class="fas fa-caret-right"></i>
</el-radio-button>
</el-radio-group>
</el-row>
<el-row>
<router-view></router-view>
</el-row>
</el-col>
</el-row>
</template>
<script>
export default {
data() {
return {
routes: this.$router.options.routes,
activeLink: null,
isCollapse: false
};
},
mounted: function () {
},
methods: {
}
};
</script>
How can I fix it so the content block will occupy 100% of the available width?
Ok, I found a simple solution.
First, I moved to element-io elements. The menu is now embedded inside el-aside tag, while the main part is embedded inside an el-container tag.
Second, I added a dynamic css class to the el-aside tag:
<el-aside v-bind:class="[isCollapse ? 'menu-collapsed' : 'menu-expanded']">
Now, if you don't want to mess around with transitions, simply add to the el-menu component :collapse-transition="false" and that's it!

Passing scoped slots through multiple components

I have 2 components. One is a list component (list.vue). The other takes a list and wraps it with other features(searchandlist). Now, from the page.vue file I would like to call SearchAndList, giving it the list, and the render props. However, I cant get the dynamic data to show, only static.
ListItems.vue
<span>
<div v-for="item in items" :key="item.id">
<slot v-bind="item"></slot>
</div>
SearchAndList.vue
<div class="clients-list">
<div class="table-responsive">
<table class="table table-striped table-hover">
<list-items :items="items">
<slot name="row"></slot>
</list-items>
</table>
</div>
</div>
page.vue
<template>
<search-and-list :items="items">
<tr slot="row">
Hi
</tr>
</search-and-list>
</template>
<script>
import SearchAndList from '../components/base/SearchAndList'
export default {
components: {
SearchAndList
},
data() {
return {
items: [
{ id: 10, name: 'Marc' },
{ id: 11, name: 'Bob' },
{ id: 12, name: 'George' }
]
}
}
}
</script>
When using this, I get Hi listed out 3 times as exprected. However, when changing this to:
<search-and-list :items="items">
<tr slot="row" slot-scope="item">
{{ item.name}}
</tr>
</search-and-list>
I do get "Duplicate presence of slot "default" found in the same render tree - this will likely cause render errors." in the console, however, even giving the default slot a name=list, the error is the same, but default is not list.
Im sure there is something simple that I am missing. Any guidance would be great.
EDIT:
I have a child component () that exposes an { item } to its parent (). However, I would like to access { item } in the grand-parent (page.vue).
There is 2 missing bit in your configuration.
Expose you item in the List.vue:
<span>
<div v-for="item in items" :key="item.id">
<slot :item="item"></slot>
</div>
Then you need to bind the name in your SearchAndList.vue.
So try:
<div class="clients-list">
<div class="table-responsive">
<table class="table table-striped table-hover">
<list-items :items="items">
<slot scope-slot="{ item }" :name="item.name"></slot>
</list-items>
</table>
</div>