vue 3 - binding problem with select, if option selected is not showing in the new values, select is in blank,but it doesn't change to ""

Viewed 1715

I have two components, an Input and a Select, the select show options depend on what you write in the Input, but if the option selected isn't showing in the new options, select the default option or the first option "select option"

When I select an option and then change the options in the Select and the option selected is not in the new options, the Select shows in blank, but when I change the option selected to "", the Select doesn't change to it(Select option that has value ""), I don't know why but if I change to other option the Select changes to it...

Example:
select option 2 > write 3 characters in the input

as you can see the varaible option_selected that is binding with the select change to "", but the select dosn't change to "Select option"

Link on the documentation for this example:
Component custom events

const input_text = {
  name: "input-min-length",
  props: {
    text: String,
    is_min_length: Boolean 
  },
  emits: ["update:text", "update:is_min_length"],
  computed: {
    is_min(){
      return this.text.length >= 3;
    }
  },
  watch: {
     is_min(new_value){
      this.$emit("update:is_min_length", this.is_min);
     }
  },
  template: `<div><input type="text" :value="text" @input="$emit('update:text', $event.target.value)" /></div>`
};

const select_options = {
  name: "select-options",
  props: {
    option_selected: String,
    is_min_length: Boolean
  },
  emits: ["update:option_selected"],
  data() {
    return {
      options: [
        {text: "Select option", value: ""},
        {text: "option 1", value: "1", is_min_length: true},
        {text: "option 2", value: "2"},
        {text: "option 3", value: "3", is_min_length: true},
      ]
    };
  },
  computed: {
    options_filtered(){
      if(this.is_min_length == false) return this.options;
      return this.options.filter((option) => option.is_min_length == true || option.value == "")
    }
  },
  watch: {
    is_min_length(){
      const is_presented = this.options_filtered.some((option) => option.value == this.option_selected)
      if (is_presented == false){
        setTimeout(() => {
          this.$emit("update:option_selected", "");
        }, 0); // I try to use setTimeout, to see if it changes at all
      }
    }
  },
  template: `
    <select :value="option_selected" @change="$emit('update:option_selected', $event.target.value)">
      <option v-for="option in options_filtered" :value="option.value" :key="option.value">
        {{ option.text }}
      </option>
    </select>
  ` 
}


const app = {
  components:{
    "input-min-length": input_text,
    "select-options": select_options
  },
  data() {
    return {
      text: "",
      is_min_length: false,
      option_selected: ""
    }
  },
  template: `
  <div>
    <input-min-length v-model:text="text" v-model:is_min_length="is_min_length" />
    <select-options v-model:option_selected="option_selected" :is_min_length="is_min_length" /><br>
    <button @click="option_selected='1'">change to opt 1</button><br>
    <button @click="option_selected=''">change to opt ""</button><br>
    <div>
      <strong>DATA:</strong><br>
      <strong>text:</strong> "{{text}}"<br>
      <strong>is_min_length:</strong> {{is_min_length}}<br>
      <strong>option_selected:</strong> "{{option_selected}}"
    </div>
  </div>`

}


Vue.createApp(app)
.mount('#app')
<script src="https://unpkg.com/vue@next"></script>
<div id="app"></div>

1 Answers

Wow, this was a tricky one to drill down into.

What is "value"

So <select> element has no HTML value attribute, but there is a el.value property provided by the DOM API on HTMLSelectElement that gives you the selected option's value. Vue provides a binding to this value property (to be used via v-model). And that's why we can simply use :value in the <select> element.

Problem with your code:

When 'option 2' is selected, and then removed from the DOM via optionsFiltered, it sets the <select> element into an invalid state. In invalid state the element's el.value returns '' (an empty string) (Note: this is DOM API, not Vue). Now your watcher on is_min_length is triggered and emits an update:option_selected event with value '' (an empty string). As you know, Vue is reactive. Since el.value is already '', I imagine Vue does not see any need to update the DOM, and hence never calls el.value = ''. (Setting el.value to empty string even though its already an empty string does give desired behaviour, DOM API seems quite robust, its Vue that's not calling it).

Solution(s):

  • The easiest way would be to set the default "Select option" value to something other that '' so that it doesn't clash with the invalid state's ''. For e.g. you can set it to 0. Or maybe a string 'none'.

const input_text = {
  name: "input-min-length",
  props: {
    text: String,
    is_min_length: Boolean 
  },
  emits: ["update:text", "update:is_min_length"],
  computed: {
    is_min(){
      return this.text.length >= 3;
    }
  },
  watch: {
     is_min(new_value){
      this.$emit("update:is_min_length", this.is_min);
     }
  },
  template: `<div><input type="text" :value="text" @input="$emit('update:text', $event.target.value)" /></div>`
};

const select_options = {
  name: "select-options",
  props: {
    option_selected: String,
    is_min_length: Boolean
  },
  emits: ["update:option_selected"],
  data() {
    return {
      options: [
        {text: "Select option", value: "0"},
        {text: "option 1", value: "1", is_min_length: true},
        {text: "option 2", value: "2"},
        {text: "option 3", value: "3", is_min_length: true},
      ]
    };
  },
  computed: {
    options_filtered(){
      if(this.is_min_length == false) return this.options;
      return this.options.filter((option) => option.is_min_length == true || option.value == "0")
    }
  },
  watch: {
    is_min_length(){
      const is_presented = this.options_filtered.some((option) => option.value == this.option_selected)
      if (is_presented == false){
        setTimeout(() => {
          this.$emit("update:option_selected", '0');
        }, 0); // I try to use setTimeout, to see if it changes at all
      }
    }
  },
  template: `
    <select :value="option_selected" @change="$emit('update:option_selected', $event.target.value)">
      <option v-for="option in options_filtered" :value="option.value" :key="option.value">
        {{ option.text }}
      </option>
    </select>
  ` 
}


const app = {
  components:{
    "input-min-length": input_text,
    "select-options": select_options
  },
  data() {
    return {
      text: "",
      is_min_length: false,
      option_selected: "0"
    }
  },
  template: `
  <div>
    <input-min-length v-model:text="text" v-model:is_min_length="is_min_length" />
    <select-options v-model:option_selected="option_selected" :is_min_length="is_min_length" /><br>
    <button @click="option_selected='1'">change to opt 1</button><br>
    <button @click="option_selected='0'">change to opt ""</button><br>
    <div>
      <strong>DATA:</strong><br>
      <strong>text:</strong> "{{text}}"<br>
      <strong>is_min_length:</strong> {{is_min_length}}<br>
      <strong>option_selected:</strong> "{{option_selected}}"
    </div>
  </div>`

}


Vue.createApp(app)
.mount('#app')
<script src="https://unpkg.com/vue@next"></script>
<div id="app"></div>

  • Another solution is to call the el.value = '' yourself alongside emitting the event, but I don't recommend it as it makes your code harder to understand for fellow Vue developers (you should adhere good coding practices even if you're the only one working on the project):

const input_text = {
  name: "input-min-length",
  props: {
    text: String,
    is_min_length: Boolean 
  },
  emits: ["update:text", "update:is_min_length"],
  computed: {
    is_min(){
      return this.text.length >= 3;
    }
  },
  watch: {
     is_min(new_value){
      this.$emit("update:is_min_length", this.is_min);
     }
  },
  template: `<div><input type="text" :value="text" @input="$emit('update:text', $event.target.value)" /></div>`
};

const select_options = {
  name: "select-options",
  props: {
    option_selected: String,
    is_min_length: Boolean
  },
  emits: ["update:option_selected"],
  data() {
    return {
      options: [
        {text: "Select option", value: ""},
        {text: "option 1", value: "1", is_min_length: true},
        {text: "option 2", value: "2"},
        {text: "option 3", value: "3", is_min_length: true},
      ]
    };
  },
  computed: {
    options_filtered(){
      if(this.is_min_length == false) return this.options;
      return this.options.filter((option) => option.is_min_length == true || option.value == "")
    }
  },
  watch: {
    is_min_length(){
      const is_presented = this.options_filtered.some((option) => option.value == this.option_selected)
      if (is_presented == false){
        setTimeout(() => {
          this.$emit("update:option_selected", "");
          document.getElementById("myselect").value = "";
        }, 0); // I try to use setTimeout, to see if it changes at all
      }
    }
  },
  template: `
    <select id="myselect" :value="option_selected" @change="$emit('update:option_selected', $event.target.value)">
      <option v-for="option in options_filtered" :value="option.value" :key="option.value">
        {{ option.text }}
      </option>
    </select>
  ` 
}


const app = {
  components:{
    "input-min-length": input_text,
    "select-options": select_options
  },
  data() {
    return {
      text: "",
      is_min_length: false,
      option_selected: ""
    }
  },
  template: `
  <div>
    <input-min-length v-model:text="text" v-model:is_min_length="is_min_length" />
    <select-options v-model:option_selected="option_selected" :is_min_length="is_min_length" /><br>
    <button @click="option_selected='1'">change to opt 1</button><br>
    <button @click="option_selected=''">change to opt ""</button><br>
    <div>
      <strong>DATA:</strong><br>
      <strong>text:</strong> "{{text}}"<br>
      <strong>is_min_length:</strong> {{is_min_length}}<br>
      <strong>option_selected:</strong> "{{option_selected}}"
    </div>
  </div>`

}


Vue.createApp(app)
.mount('#app')
<script src="https://unpkg.com/vue@next"></script>
<div id="app"></div>

Related