How to properly work with v-model and the Composition API :value="modelValue" syntax? Converting Vue2 custom input into Vue3 - vue.js

I have a useful little custom input I've been using in all of my Vue2 projects (allows you to customize with autofocus, autogrow and debounce), but now that I am working in Vue3, I've been trying to create an updated version.
I've not completed it yet, but I've come across some trouble with the Vue3's composition API :value="modelValue" syntax. In my CodeSandbox I have two inputs, one using the new syntax and the other just straight up using v-model. The later works, while the :value="valueInner" throws Extraneous non-props attributes errors.
What am I doing wrong here and how can I get this to work with that :value="modelValue" syntax?
Cheers!
Link to CodeSandbox
NOTE:
I still need to add autogrow and add all the usecases in App.vue.
CInput
<template>
<input
ref="inputRef"
data-cy="input-field"
v-if="type !== 'textarea'"
:disabled="disabled"
:type="type"
:placeholder="placeholder"
:readonly="readonly"
:required="required"
:autofocus="autofocus"
:debounce="debounce"
:value="valueInner"
/>
<!-- <input
ref="inputRef"
data-cy="input-field"
v-if="type !== 'textarea'"
:disabled="disabled"
:type="type"
:placeholder="placeholder"
:readonly="readonly"
:required="required"
:autofocus="autofocus"
:debounce="debounce"
v-model="valueInner"
/> -->
</template>
<script>
import { defineComponent, ref, onMounted, nextTick, watch } from "vue";
export default defineComponent({
props: {
/** HTML5 attribute */
disabled: { type: String },
/** HTML5 attribute (can also be 'textarea' in which case a `<textarea />` is rendered) */
type: { type: String, default: "text" },
/** HTML5 attribute */
placeholder: { type: String },
/** HTML5 attribute */
readonly: { type: Boolean },
/** HTML5 attribute */
required: { type: Boolean },
/** v-model */
modelValue: { type: [String, Number, Date], default: "" },
autofocus: { type: Boolean, default: false },
debounce: { type: Number, default: 1000 },
},
emits: ["update:modelValue"],
setup(props, { emit }) {
const inputRef = ref(null);
const timeout = ref(null);
const valueInner = ref(props.modelValue);
if (props.autofocus === true) {
onMounted(() => {
// I don't know we need nexttick
nextTick(() => {
inputRef.value.focus();
// setTimeout(() => inputRef.value.focus(), 500);
});
});
}
watch(valueInner, (newVal, oldVal) => {
const debounceMs = props.debounce;
if (debounceMs > 0) {
clearTimeout(timeout.value);
timeout.value = setTimeout(() => emitInput(newVal), debounceMs);
console.log(newVal);
} else {
console.log(newVal);
emitInput(newVal);
}
});
function emitInput(newVal) {
let payload = newVal;
emit("update:modelValue", payload);
}
// const onInput = (event) => {
// emit("update:modelValue", event.target.value);
// };
return { inputRef, valueInner };
},
});
</script>
App.vue
<template>
<CInput :autofocus="true" v-model.trim="inputValue1" />
<CInput :autofocus="false" v-model.trim="inputValue2" />
<pre>Input Value 1: {{ inputValue1 }}</pre>
<pre>Input Value 2: {{ inputValue2 }}</pre>
</template>
<script>
import { ref } from "vue";
import CInput from "./components/CInput.vue";
export default {
name: "App",
components: { CInput },
setup() {
const inputValue1 = ref("");
const inputValue2 = ref("");
return { inputValue1, inputValue2 };
},
};
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
</style>

The full warning is:
[Vue warn]: Extraneous non-props attributes (modelModifiers) were passed to component but could not be automatically inherited because component renders fragment or text root nodes.
CInput has more than one root node (i.e., the <input> and the comment node), so the component is rendered as a fragment. The .trim modifier on CInput is normally passed onto root node's v-model, as seen in this demo. Since the actual code is a fragment, Vue can't decide for you where to pass on the modelModifiers prop, leading to the warning you observed.
However, declaring a modelModifiers prop to receive the modifiers is enough to resolve the problem:
// CInput.vue
export default {
props: {
modelModifiers: {
default: () => ({})
}
}
}
demo

So far I have only built one App using Vue 3 + Vite.
I went with the <script setup> method only and stuck with it, this is how that looks when defining props:
<script setup>
import { onMounted } from 'vue'
const props = defineProps({
readonly: { type: Boolean },
required: { type: Boolean },
})
onMounted(() => {
console.dir(props.readonly)
})
</script>
In the Vite setup, defineProps is globally registered, seems to be the only method that does not require an import, I would not know if this is true of any other compiler methods.
v-model in Vue3 pops out as modelValue not value, maybe you can defineProp the modelValue unless its there by default always?
There is some explanation here: https://v3-migration.vuejs.org/breaking-changes/v-model.html#_2-x-syntax
Hope this applies to you and helps.

Related

How can I change the locale of a VueI18n instance in a Vue file, and have it apply to all other Vue files in the application?

I am currently trying to implement a feature where a user can select a language from a dropdown menu in a Settings page (SettingsDialog.vue), updating all of the text to match the new language. This application has multiple Vue files like a MenuBar.vue, HelpDialog.vue, each pulling from translation.ts for their English translations. However, I noticed that selecting a language from the dropdown menu only changed the elements inside my SettingsDialog.vue file, not all of the other Vue files I have.
I tried using the Vue-I18n documentation implementation of changing locale globally in the file. I was expecting for the locale of the entire application to change after selecting a language in SettingsDialog.vue, applying my English translations in translation.ts to the Menu Bar, Help Page, etc. What happened is that the translations from translation.ts only applied to the SettingsDialog.vue page, no where else.
I guess it would be helpful to add that this is an Electron application, and the Vue files in the project use Quasar. Each file does have the correct import statements.
main.ts:
// ...
window.datalayer = [];
const i18n = createI18n({
legacy: false,
locale: "",
messages,
});
createApp(App)
.use(store, storeKey)
.use(router)
.use(
createGtm({
id: process.env.VUE_APP_GTM_CONTAINER_ID ?? "GTM-DUMMY",
vueRouter: router,
enabled: false,
})
)
.use(Quasar, {
config: {
brand: {
primary: "#a5d4ad",
secondary: "#212121",
},
},
iconSet,
plugins: {
Dialog,
Loading,
},
})
.use(ipcMessageReceiver, { store })
.use(markdownItPlugin)
.use(i18n)
.mount("#app");
SettingsDialog.vue
// ...
<!-- Language Setting Card -->
<q-card flat class="setting-card">
<q-card-actions>
<div id="app" class="text-h5">{{ $t("言語") }}</div>
</q-card-actions>
<q-card-actions class="q-px-md q-py-sm bg-setting-item">
<div id="app">{{ $t("言語を選択する") }}</div>
<q-space />
<q-select
filled
v-model="locale"
dense
emit-value
map-options
options-dense
:options="[
{ value: 'ja', label: '日本語 (Japanese)' },
{ value: 'en', label: '英語 (English)' },
]"
label="Language"
>
<q-tooltip
:delay="500"
anchor="center left"
self="center right"
transition-show="jump-left"
transition-hide="jump-right"
>
Test
</q-tooltip>
</q-select>
</q-card-actions>
</q-card>
// ...
<script lang="ts">
import { useI18n } from "vue-i18n";
// ...
setup(props, { emit }) {
const { t, locale } = useI18n({ useScope: "global" });
// ...
return {
t,
locale,
// ...
};
MenuBar.vue
<template>
<q-bar class="bg-background q-pa-none relative-position">
<div
v-if="$q.platform.is.mac && !isFullscreen"
class="mac-traffic-light-space"
></div>
<img v-else src="icon.png" class="window-logo" alt="application logo" />
<menu-button
v-for="(root, index) of menudata"
:key="index"
:menudata="root"
v-model:selected="subMenuOpenFlags[index]"
:disable="menubarLocked"
#mouseover="reassignSubMenuOpen(index)"
#mouseleave="
root.type === 'button' ? (subMenuOpenFlags[index] = false) :
undefined
"
/>
// ...
<script lang="ts">
import { defineComponent, ref, computed, ComputedRef, watch } from "vue";
import { useStore } from "#/store";
import MenuButton from "#/components/MenuButton.vue";
import TitleBarButtons from "#/components/TitleBarButtons.vue";
import { useQuasar } from "quasar";
import { HotkeyAction, HotkeyReturnType } from "#/type/preload";
import { setHotkeyFunctions } from "#/store/setting";
import {
generateAndConnectAndSaveAudioWithDialog,
generateAndSaveAllAudioWithDialog,
generateAndSaveOneAudioWithDialog,
} from "#/components/Dialog";
import { useI18n } from "vue-i18n";
import messages from "../translation";
type MenuItemBase<T extends string> = {
type: T;
label?: string;
};
export type MenuItemSeparator = MenuItemBase<"separator">;
export type MenuItemRoot = MenuItemBase<"root"> & {
onClick: () => void;
subMenu: MenuItemData[];
};
export type MenuItemButton = MenuItemBase<"button"> & {
onClick: () => void;
};
export type MenuItemCheckbox = MenuItemBase<"checkbox"> & {
checked: ComputedRef<boolean>;
onClick: () => void;
};
export type MenuItemData =
| MenuItemSeparator
| MenuItemRoot
| MenuItemButton
| MenuItemCheckbox;
export type MenuItemType = MenuItemData["type"];
export default defineComponent({
name: "MenuBar",
components: {
MenuButton,
TitleBarButtons,
},
setup() {
const { t } = useI18n({
messages,
});
// ...
};
const menudata = ref<MenuItemData[]>([
{
type: "root",
label: t("ファイル"),
onClick: () => {
closeAllDialog();
},
// ...
]);
translation.ts
const messages = {
en: {
// MenuBar.vue
ファイル: "File",
エンジン: "Engine",
ヘルプ: "Help",
// SettingDialog.vue
言語: 'Language',
言語を選択する: 'Select Language',
オフ: 'OFF',
エンジンモード: 'Engine Mode',
// HelpDialog.vue
ソフトウェアの利用規約: 'test',
}
};
export default messages;
Maybe there are more problems but now I see two:
Your menudata should be computed instead of just ref. Right now you are creating a JS object and setting it label property to result of t() call. When global locale changes this object is NOT created again. It still holds same value the t() function returned the only time it was executed - when setup() was running
// correct
const menudata = computed<MenuItemData[]>(() => [
{
type: "root",
label: t("ファイル"),
onClick: () => {
closeAllDialog();
},
// ...
]);
This way whenever i18n.global.locale changes, your menudata is created again with new translation
As an alternative, set label to key and use t(label) inside the template. However computed is much more effective solution...
You don't need to pass messages to useI18n() in every component. Only to the global instance. By passing config object into a useI18n() in a component you are creating Local scope which makes no sense if you are storing all translations in a single global place anyway

Vue 3 Composition API - Props default and DOM inside lifecycle methods

I have a Vue component inside a NuxtJS app and I'm using the #nuxtjs/composition-api.
I have this component which is a <Link> component and I would like to make the code clearer.
I have a computed property that determines to color of my UiIcon from iconColor, iconColorHover, IconActive. But most importantly, I want to set it to a specific color if I have a disable class on my root component. It works like that but it doesn't look too good I believe.
I found out that undefined is the only value that I can use to take UiIcon default props if not defined. Empty string like '' would make more sense to more but it's considered as a valid value. I would have to do some ternary conditions in my UiIcon and I'd like to avoid that.
<template>
<div ref="rootRef" class="row">
<UiIcon
v-if="linkIcon"
:type="linkIcon"
:color="linkIconColor"
class="icon"
/>
<a
class="link"
:href="linkHref"
:target="linkTarget"
:rel="linkTarget === 'blank' ? 'noopener noreferrer' : null"
#mouseover="linkActive = true"
#mouseout="linkActive = false"
>
<slot></slot>
</a>
</div>
</template>
<script lang="ts">
import {
defineComponent,
computed,
ref,
toRefs,
nextTick,
onBeforeMount,
} from '#nuxtjs/composition-api';
import { Colors } from '~/helpers/styles';
export default defineComponent({
name: 'Link',
props: {
href: {
type: String,
default: undefined,
},
target: {
type: String as () => '_blank' | '_self' | '_parent' | '_top',
default: '_self',
},
icon: {
type: String,
default: undefined,
},
iconColor: {
type: String,
default: undefined,
},
iconHoverColor: {
type: String,
default: undefined,
},
},
setup(props) {
const { href, target, icon, iconColor, iconHoverColor } = toRefs(props);
const linkActive = ref(false);
const rootRef = ref<HTMLDivElement | null>(null);
const writableIconColor = ref('');
const linkIconColor = computed({
get: () => {
const linkDisabled = rootRef.value?.classList.contains('disabled');
if (linkDisabled) {
return Colors.DARK_GREY;
}
if (linkActive.value && iconHoverColor.value) {
return iconHoverColor.value;
}
return iconColor.value;
},
set: (value) => {
writableIconColor.value = value;
},
});
onBeforeMount(() => {
nextTick(() => {
const linkDisabled = rootRef.value?.classList.contains('disabled');
if (linkDisabled) {
linkIconColor.value = Colors.DARK_GREY;
}
});
});
return {
rootRef,
linkHref: href,
linkTarget: target,
linkIcon: icon,
linkIconColor,
linkActive,
};
},
});
</script>
Implementing disabled status for a component means it will handle two factors: style (disabled color) and function. Displaying a disabled color is only a matter of style/css. implementing it in programmatical way means it'll take longer time to render completely on user's side and it'll lose more SEO scores. examine UiIcon's DOM from browser and override styles using Deep selectors.
If I were handling this case, I would have described the color with css and try to minimize programmatic manipulation of style.
<template>
<div :disabled="disabled">
</div>
</template>
<script>
export default {
props: {
disabled: {
type: Boolean,
default: false,
}
}
}
</script>
// it does not have to be scss.
// just use anything that's
// easier to handle variables.
<style lang="scss">
// I would normally import css with prepend option from webpack,
// but this is just to illustrate the usage.
#import 'custom-styles.scss';
&::v-deep button[disabled] {
color: $disabled-color;
}
</style>
attach validator function on the props object. it'll automatically throw errors on exceptions.
{
props: {
icon: {
type: String,
default: "default-icon",
validator(val) {
return val !== "";
// or something like,
// return val.includes(['iconA', 'iconB'])
},
},
}
}

Use component based translations in child components in vue-i18n

I'm using vue-i18n to translate messages in my vue app. I have some global translations that are added in new VueI18n(...) as well as some component based translations in a component named c-parent. The component contains child components named c-child. Now, I would like to use the component based translations of c-parent also in c-child.
I made a small example in this fiddle: https://jsfiddle.net/d80o7mpL/
The problem is in the last line of the output: The message in c-child is not translated using the component based translations of c-parent.
Since global translations are "inherited" by all components, I would expect the same for component based translations (in their respective component subtree). Is there a way to achieve this in vue-i18n?
Well, you need to pass the text to child component using props.
Global translations are "inherited" by all components. But you're using local translation in child.
const globalMessages = {
en: { global: { title: 'Vue i18n: usage of component based translations' } }
}
const componentLocalMessages = {
en: { local: {
title: "I\'m a translated title",
text: "I\'m a translated text"
}}
}
Vue.component('c-parent', {
i18n: {
messages: componentLocalMessages
},
template: `
<div>
<div>c-parent component based translation: {{ $t('local.title') }}</div>
<c-child :text="$t('local.title')"></c-child>
</div>
`
})
Vue.component('c-child', {
props: ['text'],
template: `
<div>c-child translation: {{ text }}</div>
`
})
Vue.component('app', {
template: '<c-parent />'
})
const i18n = new VueI18n({
locale: 'en',
messages: globalMessages
})
new Vue({
i18n,
el: "#app",
})
body {
background: #20262E;
padding: 20px;
font-family: Helvetica;
}
#app {
background: #fff;
border-radius: 4px;
padding: 20px;
}
h5 {
margin: 1em 0 .5em 0;
}
<script src="https://unpkg.com/vue"></script>
<script src="https://unpkg.com/vue-i18n"></script>
<div id="app">
<h2>{{ $t('global.title') }}</h2>
We define two Vue components: <code><c-child/></code> contained in <code><c-parent/></code>.
<code><c-parent/></code> defines some component based translations. We would like to use the
parent's translations in the child but it does not work.
<h5>Example:</h5>
<app />
</div>
What I'm doing is using i18n.mergeLocaleMessage in router.ts to merge a particular .i18n.json translation file (by setting a meta.i18n property) for each route:
const router = new Router({
[...]
{
path: '/settings',
name: 'settings',
component: () => import('./views/Settings.vue'),
meta: {
i18n: require('./views/Settings.i18n.json'),
},
},
[...]
});
router.beforeEach((to, from, next) => {
// load view-scoped translations?
if (!!to.meta.i18n) {
Object.keys(to.meta.i18n).forEach((lang) => i18n.mergeLocaleMessage(lang, to.meta.i18n[lang]));
}
next();
});
With Settings.i18n.json being like:
{
"en":
{
"Key": "Key"
},
"es":
{
"Key": "Clave"
}
}
That way, all child components will use the same translation file.
In case you can't use vue-router, maybe you can do it in the parent component's mounted() hook (haven't tried that)
I had the same situation with i18n.
Let's say we have a "card" object prop which it includes the needed language ( was my case) that we'll use in a CardModal.vue component which will be the parent.
So what i did was get the needed locale json file ( based on the prop language) and adding those messages within the card prop.
So in the parent component we'll have:
<template>
<div id="card-modal">
<h1> {{ card.locales.title }} </h1>
<ChildComponent card="card" />
</div>
</template>
<script>
export default {
name: 'CardModal',
props: {
card: {
type: Object,
required: true,
}
}
data() {
return {
locale: this.card.language, //'en' or 'es'
i18n: {
en: require('#/locales/en'),
es: require('#/locales/es'),
},
}
},
created() {
this.card.locales = this.i18n[this.locale].card_modal
}
}
</script>
Notice that we are not relying in the plugin function anymore ( $t() ) and we are only changing the locale in the current component. I did it in this way cause i didn't want to use the "i18n" tag in each child component and wanted to keep all the locales messages in one single json file per language. I was already using the card prop in all child components so that's why i added the locales to that object.
If you need a way to change the locale using a select tag in the component, we can use a watcher for the locale data property like the docs shows

How Can I edit data after on a click event on VUE using VUEX?

I just want to edit and save content from json
Following the next question
I use deep clone to clone my data and edit in the computed object than sent it to the central object on Vuex
The code works just for the first time, I can edit the data and after press edit it changes the data... but if I try to edit again... I get the error
[Vue warn]: Error in callback for watcher "function () { return this._data.$$state }": "Error: [vuex] do not mutate vuex store state outside mutation handlers."
component.js
<template>
<div class="hello">
<form #submit.prevent="saveForm();">
<input type="text" v-model="contentClone.result" />
<button type="submit">edit</button>
<p>{{ contentClone.result }}</p>
</form>
</div>
</template>
<script>
export default {
name: "HelloWorld",
data() {
return {
content: {},
contentClone: {}
};
},
methods: {
saveForm(event) {
this.$store.dispatch("UPDATE_CONTENT", this.contentClone);
}
},
beforeMount() {
this.contentClone = JSON.parse(this.contentState);
},
computed: {
contentState() {
return JSON.stringify({ ...this.$store.getters["getContent"] });
}
}
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
h1,
h2 {
font-weight: normal;
}
ul {
list-style-type: none;
padding: 0;
}
li {
display: inline-block;
margin: 0 10px;
}
a {
color: #42b983;
}
</style>
store.js
import {
UPDATE_CONTENT
} from "../actions/user";
import Vue from "vue";
const state = {
content: { result: "original content" },
status: String,
errorMessage: []
};
const getters = {
getContent: state => state.content
};
const actions = {
[UPDATE_CONTENT]: ({ commit }, payload) => {
commit(UPDATE_CONTENT, payload);
}
};
const mutations = {
[UPDATE_CONTENT]: (state, payload) => {
Vue.set(state, "content", payload);
}
};
export default {
state,
getters,
actions,
mutations
};
I replicated the error in the link above,
https://codesandbox.io/s/p7yppolw00
if I could just restart the content in the component after saving it, I think that might fix the error
You are saving an object this.contentClone as state, which is further bound with input and this makes input able to directly makes changes to your vuex store through v-model and hence the error; A simple fix would be clone this.contentClone when dispatch it to vuex state:
saveForm(event) {
this.$store.dispatch("UPDATE_CONTENT", JSON.parse(JSON.stringify(this.contentClone)));
}
Or IMO a better solution would be to dispatch the result as a string instead of using an object. See the working example: https://codesandbox.io/s/mmpr4745z9

Ensure that Vuex state is loaded before render component

I have a add-user.vue component. It is my template for adding a new user and editing a existing user. So on page load I check if route has a id, if so I load a user from a state array to edit it. My issue is that user is undefined because the state array users is empty. How can I ensure that my user object isn't undefined. It does load sometimes but on refresh it doesn't. I thought I had it covered but nope. This is my setup. What am I missing here?
Store
state: {
users: []
},
getters: {
users: state =>
_.keyBy(state.users.filter(user => user.deleted === false), 'id')
},
actions: {
fetchUsers({
commit
}) {
axios
.get('http://localhost:3000/users')
.then(response => {
commit('setUsers', response.data);
})
.catch(error => {
console.log('error:', error);
});
}
}
In my add-user.vue component I have the following in the data() computed:{} and created()
data() {
return {
user: {
id: undefined,
name: undefined,
gender: undefined
}
};
},
computed: {
...mapGetters(['users'])
},
created() {
if (this.$route.params.userId === undefined) {
this.user.id = uuid();
...
} else {
this.user = this.users[this.$route.params.userId];
}
}
Template
<template>
<div class="add-user" v-if="user"></div>
</template>
My User.vue I have the following setup, where I init the fetch of users on created()
<template>
<main class="main">
<AddUser/>
<UserList/>
</main>
</template>
<script>
import AddUser from '#/components/add-user.vue';
import UserList from '#/components/user-list.vue';
export default {
name: 'User',
components: {
AddUser,
UserList
},
created() {
this.$store.dispatch('fetchUsers');
}
};
</script>
I have tried this. When saving in my editor it works but not on refresh. The dispatch().then() run before the mutation setting the users does.
created() {
if (this.$route.params.userId === undefined) {
this.user.id = uuid();
...
} else {
if (this.users.length > 0) {
this.user = this.users[this.$route.params.userId];
} else {
this.$store.dispatch('fetchUsers').then(() => {
this.user = this.users[this.$route.params.userId];
});
}
}
}
I would use beforeRouteEnter in User.vue so that the component is not initialized before the data is loaded.
(Assuming you are using vue-router)
beforeRouteEnter (to, from, next) {
if (store.state.users.length === 0) {
store.dispatch(fetchUsers)
.then(next);
}
},
You'll need to
import store from 'path/to/your/store'
because this.$store is not available until the component is initialized.
Although this solved, but I will answer for future comers. You may issue the dispatch beforehand as Shu suggested, or you may still dispatch on same component mounted hook, but use some state variable to track the progress:
data:{
...
loading:false,
...
},
...
mounted(){
this.loading = true,
this.$store
.dispatch('fetchUsers')
.finally(() => (this.loading=false));
}
Then in your template you use this loading state variable to either render the page or to render some spinner or progress bar:
<template>
<div class='main' v-if="!loading">
...all old template goes her
</div>
<div class="overlay" v-else>
Loading...
</div>
</template>
<style scoped>
.overlay {
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
color: #FFFFFF;
}
</style>
For this particular case it was just going back and forth to the same vue instance. Solved it by adding :key="some-unique-key", so it looks like this.
<template>
<main class="main">
<AddUser :key="$route.params.userId"/>
<UserList/>
</main>
</template>