Search code examples
javascripthtmlvue.jsvuejs3

Input clientWidth and scrollWidth are always equal


I'm using Vuetify text-fields and want to display a tooltip containing the content if the content is greater than the field width (user needs to scroll). The tooltip should only appear on hover (default behaviour). I started with the following

(Playground)

<script setup lang="ts">
  import { ref, computed } from "vue";

  const currentValue = ref("");
  const textFieldComponent = ref<VTextField>();

  const isTextFieldCuttingOffContent = computed(() => {
    if (!textFieldComponent.value) {
      return false;
    }

    if (!currentValue.value) {
      return false;
    }

    return (
      textFieldComponent.value.$el.clientWidth <
      textFieldComponent.value.$el.scrollWidth
    );
  });
</script>

<template>
  <v-container style="width: 300px">
    <v-tooltip :text="currentValue" :disabled="!isTextFieldCuttingOffContent">
      <template v-slot:activator="{ props }">
        <div v-bind="props">
          <v-text-field
            ref="textFieldComponent"
            label="label goes here"
            v-model="currentValue"
          />
        </div>
      </template>
    </v-tooltip>
  </v-container>
</template>

I also tried to use a watcher instead of a computed prop (Playground)

The problem is that isTextFieldCuttingOffContent always returns false because clientWidth and scrollWidth are always equal. Do you have any ideas what's wrong or missing?


Solution

  • 1.) watch() variable, use nextTick()

    The scrollWidth changes as a result of DOM manipulation. If you console.log the current scrollWidth of the input field, it will indeed change correctly in your code. However, the issue here is that these DOM data are not automatically updated in the Vue reactivity system.

    To retrieve the updated values, you can use nextTick(). However, it's important to await the value of nextTick() to ensure that the DOM manipulation is completed. Therefore, you should call it within an async function. Using computed() is typically not suitable for this purpose. Instead, it would be better to check if the scrollWidth has changed only when the value of interest changes.

    To achieve this, you can use the watch() function. You specify which variable's changes to observe and which function to execute when a change is detected. In our case, we will monitor the currentValue variable and execute an async function. So when the currentValue changes, the async function is executed, waits for the update using nextTick(), and then checks the difference between the new clientWidth and scrollWidth. The true/false value is then stored in a separate variable that can be referenced in your code.

    <script setup lang="ts">
      import { ref, watch, nextTick } from "vue";
    
      const currentValue = ref("");
      const textFieldComponent = ref<VTextField>();
    
      // store boolean for disabled checking
      const isTextFieldCuttingOffContent = ref(false);
    
      // checks if the current scrollWidth of the input field is wider than the clientWidth
      const checkTextOverflow = async () => {
        await nextTick();
    
        const inputWidth = textFieldComponent.value.clientWidth;
        const textWidth = textFieldComponent.value.scrollWidth;
    
        isTextFieldCuttingOffContent.value = textWidth > inputWidth;
      };
    
      // call checkTextOverflow() function when currentValue changed
      watch(currentValue, checkTextOverflow);
    </script>
    
    <template>
      <v-container style="width: 300px">
        <v-tooltip :text="currentValue" :disabled="!isTextFieldCuttingOffContent">
          <template v-slot:activator="{ props }">
            <div v-bind="props">
              <v-text-field
                id="here"
                ref="textFieldComponent"
                label="label goes here"
                v-model="currentValue"
              />
            </div>
          </template>
        </v-tooltip>
      </v-container>
    </template>
    
    Example

    const { createApp, ref, watch, nextTick } = Vue
    
    const app = createApp({
      setup() {
        const currentValue = ref('')
        const textFieldComponent = ref(null)
    
        // store boolean for disabled checking
        const isTextFieldCuttingOffContent = ref(false)
    
        // checks if the current scrollWidth of the input field is wider than the clientWidth
        const checkTextOverflow = async () => {
          await nextTick() // check DOM updates
    
          const inputWidth = textFieldComponent.value.clientWidth
          const textWidth = textFieldComponent.value.scrollWidth
    
          isTextFieldCuttingOffContent.value = textWidth > inputWidth
        }
    
        // call checkTextOverflow() function when currentValue changed
        watch(currentValue, checkTextOverflow)
        
        return { currentValue, textFieldComponent, isTextFieldCuttingOffContent }
      },
    }).mount('#app')
    .container {
      width: 100px;
      resize: both;
      overflow: hidden;
    }
      
    input {
      width: 100%;
      height: 100%;
      box-sizing: border-box;
    }
    <!-- WithNextTick.vue -->
    
    <script src="https://unpkg.com/[email protected]/dist/vue.global.prod.js"></script>
    
    <div id="app">
      <div class="container">
        <input ref="textFieldComponent" v-model="currentValue">
      </div>
      <p v-if="isTextFieldCuttingOffContent">Warning: value overflow detected</p>
    </div>

    example # 1 - detect input.value overflow


    Upgrade (2023-06-19 #1) (inspired by @tao's comment)

    2.) watch() variable, use Observer

    If you need to not only check the scrollWidth vs clientWidth during typing but also during any manipulation of the width, such as resizing or other changes, then feel free to implement the Observer solution mentioned by @tao! However, it is important to note that my solution is still essential in this case because it primarily focuses on observing the scrollWidth changes during typing, which the Observer cannot directly track as it primarily monitors DOM manipulations and/or element resizing, which are not triggered when typing into the input field or modifying the variable with JavaScript.

    To understand how Observers work, please read @tao's informative answer.

    import { ref, onMounted } from 'vue'
    
    let resizeObserver = null
    let mutationObserver = null
    
    onMounted(() => {
      // declare ResizeObserver to textFieldComponent
      resizeObserver = new ResizeObserver(checkTextOverflow)
      resizeObserver.observe(textFieldComponent.value)
    
      // declare MutationObserver to textFieldComponent
      mutationObserver = new MutationObserver(checkTextOverflow)
      mutationObserver.observe(textFieldComponent.value, {
        childList: true,
        subtree: true,
        characterData: true,
        attributes: true
      })
    })
    
    Example

    const { createApp, ref, watch, onMounted } = Vue
    
    const app = createApp({
      setup() {
        let resizeObserver = null
        let mutationObserver = null
    
        const currentValue = ref('')
        const textFieldComponent = ref(null)
    
        // store boolean for disabled checking
        const isTextFieldCuttingOffContent = ref(false)
    
        // checks if the current scrollWidth of the input field is wider than the clientWidth
        const checkTextOverflow = () => { 
          const inputWidth = textFieldComponent.value?.clientWidth || 0
          const textWidth = textFieldComponent.value?.scrollWidth || 0
    
          isTextFieldCuttingOffContent.value = textWidth > inputWidth
        }
    
        // call checkTextOverflow() function when currentValue changed
        watch(currentValue, checkTextOverflow)
    
        // run function after dom loaded
        onMounted(() => {
          // declare ResizeObserver to textFieldComponent
          resizeObserver = new ResizeObserver(checkTextOverflow)
          resizeObserver.observe(textFieldComponent.value)
    
          // declare MutationObserver to textFieldComponent
          mutationObserver = new MutationObserver(checkTextOverflow)
          mutationObserver.observe(textFieldComponent.value, {
            childList: true,
            subtree: true,
            characterData: true,
            attributes: true
          })
        })
        
        return { currentValue, textFieldComponent, isTextFieldCuttingOffContent }
      },
    }).mount('#app')
    .container {
      width: 100px;
      resize: both;
      overflow: hidden;
    }
      
    input {
      width: 100%;
      height: 100%;
      box-sizing: border-box;
    }
    <!-- WithObserver.vue -->
    
    <script src="https://unpkg.com/[email protected]/dist/vue.global.prod.js"></script>
    
    <div id="app">
      <div class="container">
        <input ref="textFieldComponent" v-model="currentValue">
      </div>
      <p v-if="isTextFieldCuttingOffContent">Warning: value overflow detected</p>
    </div>

    example # 2 - detect input.value overflow with observer


    Summary

    Monitoring the variable is necessary and unavoidable in any case. Additionally, with the feature mentioned by @tao, you can also consider unexpected events such as resizing the browser window to ensure that the tooltip display works as expected. It depends on the specific circumstances and requirements whether you need anything beyond monitoring the variable.

    I have prepared a sample code snippet for demonstration purposes so that you can compare the codes and the results.

    Try solutions on Vue SFC Playground