How to correctly reference HTMLElement in VueJs component - vue-component

I have simple component, that wraps text area. And I've another simple component, that renders a button. I want to set focus to text area when clicking the button.
This simplified example fails:
<template>
<MyCommand #resize="testResize" />
<TextArea ref="refElement" />
</template>
<script lang="ts">
// ...
export default defineComponent({
name: 'SimpleComponent',
setup(props, context) {
const refElement = ref<HTMLElement | null>(null)
const testResize = () => {
console.log('resize test')
if (refElement.value !== null) {
refElement.value.focus()
}
}
return {
refElement,
testResize,
}
}
</script>
TextArea is very simple component, some input normalization, oversimplified:
<template>
<textarea v-model.trim="value" />
</template>
I get "resize test" in console, so testResize method is running, but refElement is null.

When referencing component, not a HTML element, component type should be DefineComponent instead of HTMLElement.
Wrapped element could be referenced through $el property:
<template>
<MyCommand #resize="testResize" />
<TextArea ref="refElement" />
</template>
<script lang="ts">
// ...
export default defineComponent({
name: 'SimpleComponent',
setup(props, context) {
const refElement = ref<DefineComponent>()
const testResize = () => {
console.log('resize test')
if (refElement.value) {
refElement.value.$el.focus()
}
}
return {
refElement,
testResize,
}
}
</script>
I'm not sure if this is the "best practice", it looks to me as a hack. If anyone knows better solution, please comment.

You're missing the refElement in your return
return {
testResize, refElement
}
Update
If you are dealing with a component it becomes a bit trickier. while you can use refElement.value.$el, I'd say it's not a good idea. This will only work if the component has the first child the textarea. This will make for a brittle implementation, where if you need to change that at some point, it will break. IMHO, you're better off passing the ref as a prop to the child component. This is also not best practice, because you're supposed to pass props down and emit events up, but that that would be quite the overhead to implement. Passing ref as a prop comes with it's own issues though. If you have a ref in the template, it gets automagicaly converted from the ref/propxy to a value. To get around that, you can pass the prop in a function refElement: () => refElement in the setup(can't do it template). Of course, YMMV, but this is the path I'd chose.
const app = Vue.createApp({
setup(props, context) {
const refElement = Vue.ref(null)
const testResize = () => {
if (refElement.value !== null) {
refElement.value.focus()
}
}
return {
testResize,
refElement: () => refElement
}
}
});
app.component("text-area", {
template: `<textarea ref="taRef"></textarea></div></div>`,
props: {
textarearef: {
type: Function
}
},
setup(props) {
const taRef = props.textarearef()
return {
taRef
}
}
})
app.mount("#app");
<script src="https://unpkg.com/vue#3.0.3/dist/vue.global.prod.js"></script>
<div id="app">
<text-area :textarearef="refElement"></text-area>
<button #click="testResize">🦄</button>
</div>

Related

Why the context.slots in the Vue setup() function has an empty object?

Accordingly to this Issue it should work with the current version v3.2.x.
But it doesn't.
Here is the playground:
const { createApp } = Vue;
const myComponent = {
template: '#my-component',
setup(props, { slots }) {
console.log(slots)
}
}
const App = {
components: {
myComponent
}
}
const app = createApp(App)
app.mount('#app')
<div id="app">
<my-component>Default
<template #footer>Footer</template>
</my-component>
</div>
<script src="https://unpkg.com/vue#3/dist/vue.global.prod.js"></script>
<script type="text/x-template" id="my-component">
<div>
<slot></slot>
<hr/>
<slot name="footer"></slot>
</div>
</script>
The solution was provided by Duannx.
With console.log(slots) they are listed correctly.
{
"footer": (...n)=>{o._d&&Tr(-1);const r=hn(t);let s;try{s=e(...n)}finally{hn(r),o._d&&Tr(1)}return s},
"default": (...n)=>{o._d&&Tr(-1);const r=hn(t);let s;try{s=e(...n)}finally{hn(r),o._d&&Tr(1)}return s}
}
Explanation
JSON.stringify doesn't show the slots since they are functions.
Here is the explanation from the MDN Docs JSON.stringify():
undefined, Function, and Symbol values are not valid JSON values. If any such values are encountered during conversion, they are either omitted (when found in an object) or changed to null (when found in an array). JSON.stringify() can return undefined when passing in "pure" values like JSON.stringify(() => {}) or JSON.stringify(undefined).
Example
console.log("JSON.stringify(() => {}): " + JSON.stringify(() => {}));
console.log(JSON.stringify({ "func": function () {}, "lmbd": () => {} }))

Storybook Vue3 - Work with v-model in stories

I have a question regarding Storybook and Vue components with v-models. When writing a story for let's say an input component with a v-model i want a control reflecting the value of this v-model. Setting the modelValue from the control is no problem, but when using the component itself the control value stays the same. I am searching the web for a while now but i can't seem to find a solution for this.
A small example:
// InputComponent.vue
<template>
<input
type="text"
:value="modelValue"
#input="updateValue"
:class="`form-control${readonly ? '-plaintext' : ''}`"
:readonly="readonly"
/>
</template>
<script lang="ts">
export default {
name: "GcInputText"
}
</script>
<script lang="ts" setup>
defineProps({
modelValue: {
type: String,
default: null
},
readonly: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update:modelValue']);
const updateValue = (event: Event) => {
const target = event.target as HTMLInputElement;
emit('update:modelValue', target.value);
}
</script>
In Storybook:
Does anyone have a solution to make this working?
Thanks in advance!
In my case, I have a custom select input that uses a modelValue prop.
I tried this and worked for me:
at my-component.stories.js:
import { ref } from 'vue'
import MyComponent from './MyComponent.vue'
export default {
title: 'Core/MyComponent',
component: MyComponent,
argTypes: { }
}
const Template = (args) => ({
components: { MyComponent },
setup() {
let model = ref('Javascript')
const updateModel = (event) => model.value = event
return { args, model, updateModel }
},
template: '<my-component v-bind="args" :modelValue="model" #update:modelValue="updateModel" />'
})
export const Default = Template.bind({})
Default.args = {
options: [
'Javascript',
'PHP',
'Java'
]
}

How to test computed value inside setup function in Vue.js 3 with vue-test-utils & Jest

I am getting "TypeError: Cannot add property myData, object is not extensible" on setData
Hello.vue
<template>
<div v-if="isEditable" id="myEditDiv">
<button type="button"> Edit </button>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, reactive} from "vue"
export default defineComponent({
setup() {
const myObject = {myName:"", myNumber:""}
let myData = reactive({myObject})
const isEditable = computed(() => {
return myData.myObject.myName.startsWith('DNU') ? false : true
})
return {
isEditable
}
}
})
</script>
Hello.spec.ts
import { shallowMount } from '#vue/test-utils'
import Hello from '#/components/Hello.vue'
import { reactive } from 'vue'
describe('Hello.vue Test', () => {
it('is isEditable returns FALSE if NAME starts with DNU', async () => {
const myObject = {myName:"DNU Bad Name", myNumber:"12345"}
let myData = reactive({myObject})
const wrapper = shallowMount(Hello)
await wrapper.setData({'myData' : myData})
expect(wrapper.vm.isEditable).toBe(false)
})
})
I also tried to see if that DIV is visible by:
expect(wrapper.find('#myEditDiv').exists()).toBe(false)
still same error. I might be completely off the path, so any help would be appreciated.
Update
This is possible several different ways. There's two issues that need to be addressed.
The variable has to be made available. You can use vue's expose function in setup (but getting the value is really messy: wrapper.__app._container._vnode.component.subTree.component.exposed😱) or just include it in the return object (accessible through wrapper.vm).
change how you mutate the data in the test.
your test has
const myObject = {myName:"DNU Bad Name", myNumber:"12345"}
let myData = reactive({myObject})
const wrapper = shallowMount(Hello)
await wrapper.setData({'myData' : myData})
even if setData was able to override the internal, it would not work.
the problem is that the setup function has this
let myData = reactive({ myObject });
const isEditable = computed(() => {
return myData.myObject.myName.startsWith("DNU") ? false : true;
});
where editable is using a computed generated from that instance of myData. If you override myData with a separate reactive, the computed will still continue to use the old one. You need to replace the contents of the reactive and not the reactive itself
To update the entire content of the reactive, you can use:
Object.assign(myReactive, myNewData)
you can make that a method in your component, or just run that from the test. If you update any value within the reactive (like myData.myObject) you can skip the Object.asign
Here are several versions of how you can test it.
Component:
<template>
<div v-if="isEditable" id="myEditDiv">
<button type="button">Edit</button>
</div>
</template>
<script>
import { computed, defineComponent, reactive } from "vue";
export default defineComponent({
setup(_, { expose }) {
const myObject = { myName: "", myNumber: "" };
let myData = reactive({ myObject });
const isEditable = computed(() => {
return myData.myObject.myName.startsWith("DNU") ? false : true;
});
const updateMyData = (data) => Object.assign(myData, data);
expose({ updateMyData });
return {
isEditable,
updateMyData,
myData
};
},
});
</script>
the test
import { shallowMount } from "#vue/test-utils";
import MyComponent from "#/components/MyComponent.vue";
const data = { myObject: { myName: "DNU Bad Name" } };
describe("MyComponent.vue", () => {
it.only("sanity test", async () => {
const wrapper = shallowMount(MyComponent);
expect(wrapper.vm.isEditable).toBe(true);
});
it.only("myData", async () => {
const wrapper = shallowMount(MyComponent);
Object.assign(wrapper.vm.myData, data);
expect(wrapper.vm.isEditable).toBe(false);
});
it.only("myData", async () => {
const wrapper = shallowMount(MyComponent);
wrapper.vm.myData.myObject = data.myObject;
expect(wrapper.vm.isEditable).toBe(false);
});
it.only("updateMyData method via return", async () => {
const wrapper = shallowMount(MyComponent);
wrapper.vm.updateMyData(data);
expect(wrapper.vm.isEditable).toBe(false);
});
it.only("updateMyData method via expose🙄", async () => {
const wrapper = shallowMount(MyComponent);
wrapper.__app._container._vnode.component.subTree.component.exposed.updateMyData(
data
);
expect(wrapper.vm.isEditable).toBe(false);
});
});
It is not possible through setData
from the docs:
setData
Updates component internal data.
Signature:
setData(data: Record<string, any>): Promise<void>
Details:
setData does not allow setting new properties that are not defined in the component.
Also, notice that setData does not modify composition API setup() data.
It seems that updating internals with composition API is incompatible with setData. See the method name setData, refers to this.data and was likely kept in the vue test utils mostly for backwards compatibility.
I suspect the theory is that it's bad practice anyway to test, what would be considered, an implementation detail and the component test should focus on validating inputs an outputs only. Fundamentally though, this is a technical issue, because the setup function doesn't expose the refs and reactives created in the setup.
There is a MUCH easier way to do this.....
Put your composables in a separate file
Test the composables stand alone.
Here is the vue file:
<template>
<div>
<div>value: {{ counter }}</div>
<div>isEven: {{ isEven }}</div>
<button type="button" #click="increment">Increment</button>
</div>
</template>
<script setup lang='ts'>
import {sampleComposable} from "./sample.composable";
const {isEven, counter, increment} = sampleComposable();
</script>
Here is the composable:
import {computed, ref} from 'vue';
export function sampleComputed() {
const counter = ref(0);
function increment() {
counter.value++;
}
const isEven = computed(() => counter.value % 2 === 0);
return {counter, increment, isEven};
}
Here is the test:
import {sampleComposable} from "./sample.composable";
describe('sample', () => {
it('simple', () => {
const computed = sampleComposable();
expect(computed.counter.value).toEqual(0);
expect(computed.isEven.value).toEqual(true);
computed.increment();
expect(computed.counter.value).toEqual(1);
expect(computed.isEven.value).toEqual(false);
computed.increment();
expect(computed.counter.value).toEqual(2);
expect(computed.isEven.value).toEqual(true);
})
});
This just 'works'. You don't have to deal w/ mounting components or any other stuff, you are JUST TESTING JAVASCRIPT. It's faster and much cleaner. It seems silly to test the template anyway.
One way to make this easier to test is to put all of your dependencies as arguments to the function. For instance, pass in the props so it's easy to just put in dummy values as need. Same for emits.
You can tests watches as well. You just need to flush the promise after setting the value that is being watched:
composable.someWatchedThing.value = 6.5;
await flushPromises();
Here is my flushPromises (which I found here):
export function flushPromises() {
return new Promise(process.nextTick);
}

Vue3 Wait till Child Component is Mounted

I want to wait for a child component to mount before rendering a tooltip. While waiting, I will have a placeholder to display to the user.
An example component would be:
<template>
<div v-if="!mounted">Loading...</div>
<child-component></child-component>
</template>
<script>
export default defineComponent({
setup() {
const mounted = ref(false);
return {
mounted
}
}
});
</script>
After doing research, it looks like Vue2 supports life-cycle hooks on the components, which would change the above code to:
<template>
<div v-if="!mounted">Loading...</div>
<child-component #hook:mounted="mountedCheck"></child-component>
</template>
<script>
export default {
export default defineComponent({
setup() {
const mounted = ref(false);
const mountedCheck = () = {
mounted.value = true;
}
return {
mounted, mountedCheck
}
}
});
}
However, I cant seem to get the #hook:mounted to work. Is there something similar in Vue3, or am I missing something?
The syntax has changed in Vue3. Now you need to use #vue:mounted instead of #hook:mounted.
See Vue 3 Migration Guide - VNode Lifecycle Events for details
Also, keep in mind that some event names like destroyed and beforeDestroy have been renamed to unmounted and beforeUnmount respectively in Vue3
The child component needs to emit that hook
child:
<script>
export default defineComponent({
setup() {
onMounted(() => {
const { emit } = getCurrentInstance();
emit('mounted');
})
return {};
}
});
</script>
parent
<template>
<div v-if="!mounted">Loading...</div>
<child-component #mounted="mountedCheck"></child-component>
</template>

Dynamic component in Vue3 Composition API

A simple working example of a Vue2 dynamic component
<template>
<div>
<h1>O_o</h1>
<component :is="name"/>
<button #click="onClick">Click me !</button>
</div>
</template>
<script>
export default {
data: () => ({
isShow: false
}),
computed: {
name() {
return this.isShow ? () => import('./DynamicComponent') : '';
}
},
methods: {
onClick() {
this.isShow = true;
}
},
}
</script>
Everything works, everything is great. I started trying how it would work with the Composition API.
<template>
<div>
<h1>O_o</h1>
<component :is="state.name"/>
<button #click="onClick">Click me !</button>
</div>
</template>
<script>
import {ref, reactive, computed} from 'vue'
export default {
setup() {
const state = reactive({
name: computed(() => isShow ? import('./DynamicComponent.vue') : '')
});
const isShow = ref(false);
const onClick = () => {
isShow.value = true;
}
return {
state,
onClick
}
}
}
</script>
We launch, the component does not appear on the screen, although no errors are displayed.
You can learn more about 'defineAsyncComponent' here
https://labs.thisdot.co/blog/async-components-in-vue-3
or on the official website
https://v3.vuejs.org/api/global-api.html#defineasynccomponent
import { defineAsyncComponent, defineComponent, ref, computed } from "vue"
export default defineComponent({
setup(){
const isShow = ref(false);
const name = computed (() => isShow.value ? defineAsyncComponent(() => import("./DynamicComponent.vue")): '')
const onClick = () => {
isShow.value = true;
}
}
})
Here is how you can load dynamic components in Vue 3. Example of dynamic imports from the icons collection inside /icons folder prefixed with "icon-".
BaseIcon.vue
<script>
import { defineComponent, shallowRef } from 'vue'
export default defineComponent({
props: {
name: {
type: String,
required: true
}
},
setup(props) {
// use shallowRef to remove unnecessary optimizations
const currentIcon = shallowRef('')
import(`../icons/icon-${props.name}.vue`).then(val => {
// val is a Module has default
currentIcon.value = val.default
})
return {
currentIcon
}
}
})
</script>
<template>
<svg v-if="currentIcon" width="100%" viewBox="0 0 24 24" :aria-labelledby="name">
<component :is="currentIcon" />
</svg>
</template>
You don't need to use computed or watch. But before it loads and resolved there is nothing to render, this is why v-if used.
UPD
So if you need to change components (icons in my case) by changing props use watchEffect as a wrapper around the import function.
watchEffect(() => {
import(`../icons/icon-${props.name}.vue`).then(val => {
currentIcon.value = val.default
})
})
Don't forget to import it from vue =)
The component should be added to components option then just return it name using the computed property based on the ref property isShow :
components:{
MyComponent:defineAsyncComponent(() => import("./DynamicComponent.vue"))
},
setup(){
const isShow = ref(false);
const name = computed (() => isShow.value ? 'MyComponent': '')
const onClick = () => {
isShow.value = true;
}
}
Instead of string you should provide Component
<script setup>
import Foo from './Foo.vue'
import Bar from './Bar.vue'
</script>
<template>
<component :is="Foo" />
<component :is="someCondition ? Foo : Bar" />
</template>

Resources