0

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?

rozsazoltan
  • 2,831
  • 2
  • 7
  • 20
baitendbidz
  • 187
  • 3
  • 19

3 Answers3

3

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/vue@3.3.4/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>



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/vue@3.3.4/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>



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

rozsazoltan
  • 2,831
  • 2
  • 7
  • 20
  • This is an incomplete answer. You need a [MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) on the element, because `.clientWidth` and `.scrollWidth` can change due to other reasons apart from changing the text itself (e.g: `window.resize`, changes in size of previous siblings or any of its ancestors, change of page font size, etc...). A MutationObserver will update when any of those changes happen. Note: there are libraries which offer element mutation observers, so you don't have to code it yourself. – tao Jun 19 '23 at 13:10
  • The right way to do this is to have appropriate values for `clientWidth` and `scrollWidth` in the model (e.g: script), informed (e.g: changed) by the MutationObserver. Without `nextTick()` and without `watch`. And `isTextFieldCuttingOffContent` can be a computed at that point. – tao Jun 19 '23 at 13:13
  • 1
    Actually, thinking about it, you need MutationObserver and ResizeObserver to cover all possible cases. Because MutationObserver won't catch changes in size of parents. – tao Jun 19 '23 at 13:32
  • You're correct, text changes still need to trigger the update. My answer didn't focus on that since I considered it "covered". The code in my example is not an exhaustive solution, I just wanted to outline the principle. Implementation details might differ from case to case. – tao Jun 19 '23 at 21:01
2

@rozsazoltan's answer 1 (wrapping the difference between .scrollWidth and .clientWidth into a nextTick(), which waits for DOM updates) is a quick fix which will cover most of the cases. Most likely, it is what you need in the current case.

But, since you asked for a canonical answer (which means you want something which works in all possible cases), that solution is not enough, because it assumes .clientWidth and .scrollWidth of the element only change when the text content change, which is not true.

They can also change on:

  • changes in size to any of the element's ancestors (including window object) or to its proceeding siblings
  • changes to CSS property values (own or inherited) resulting in different wrapping of the text (e.g: font-size, font-family, white-space, word-wrap, etc...)

In order to catch (and therefore react to) all those changes, you need:

  • two observers on the element (a ResizeObserver and a MutationObserver)
  • a function reading the element's current .scrollWidth and .clientWidth values, placing them somewhere in component state. With this setup the difference between the two will be reactive without the need for nextTick(). And it will cover all cases.

Proof of concept (pseudo-code):

<!-- ... -->
  <v-tooltip :disabled="!state.hasTooltip">
   <!-- ... -->
     <v-text-field
       ref="textFieldComponent"
       v-model="currentValue"
     />
import { useResizeObserver, useMutationObserver } from '@vueuse/core'

const currentValue = ref('')

const state = reactive({
  scrollWidth: 0,
  clientWidth: 0,
  hasTooltip: computed(
    () => state.clientWidth > state.scrollWidth
  )
})

const updateState = () => {
  state.scrollWidth = +textFieldComponent.value.scrollWidth || 0
  state.clientWidth = +textFieldComponent.value.clientWidth || 0
}

watch(currentValue, updateState, { immediate: true })

onMounted(() => {
  useResizeObserver(textFieldComponent.value, updateState)
  useMutationObserver(textFieldComponent.value, updateState)
})

If you need an actual implementation example, let me know and I'll create one starting from your sandbox.


1 - at the time I answered rozsazolnan's answer didn't yet contain the resize and mutation observers, which is the main reason I added mine (and also to showcase a slightly different syntax). Now Zoltan's answer is complete.

tao
  • 82,996
  • 16
  • 114
  • 150
-1

Checking the clientWidth and scrollWidth properties could be your issue. Timing may be the culprit here – specifically, the moment when you attempt to access them. The final dimensions of the element may not have been established yet or the rendering process may not be complete.

Incorporating a reactive watcher can guarantee precise measurements by constantly checking the dimensions when currentValue undergoes a change. To implement this technique, here is a revised version of your code.

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

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

  const checkTextOverflow = () => {
    if (!textFieldComponent.value) {
      return;
    }

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

    const el = textFieldComponent.value.$el;
    isTextFieldCuttingOffContent.value = el.clientWidth < el.scrollWidth;
  };

  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
            ref="textFieldComponent"
            label="label goes here"
            v-model="currentValue"
          />
        </div>
      </template>
    </v-tooltip>
  </v-container>
</template>

Whenever the value of currentValue changes, the checkTextOverflow function is called to guarantee that the measurements of clientWidth and scrollWidth are taken after the DOM updates.

Using a reactive watcher to modify the isTextFieldCuttingOffContent variable allows for precise identification of when the text field is truncating content. :) Hope this helps!

Qasim Parry
  • 192
  • 1
  • Unfortunately this doesn't work, I tried your code in the playground https://shorturl.at/ptQRU – baitendbidz Jun 19 '23 at 06:13
  • @Qasim Parry, Please make sure to check the correctness of the posted code! In its current form, it does not work, has not been tested, and is misleading. – rozsazoltan Jun 19 '23 at 09:12
  • **1.)** You need use [`nextTick()`](https://stackoverflow.com/questions/47634258/what-is-nexttick-and-what-does-it-do-in-vue-js) to can check DOM updates. **2.)** You need to use an async function because of the `nextTick()`. **3.)** You cannot assign an async function to `computed()`, as it would be a logical contradiction. So, we need to find an alternative solution, and that's how we come to using `watch()`. – rozsazoltan Jun 19 '23 at 09:12
  • By the way, **using `watch()` is indeed a better approach here because the `scrollWidth` can only change when the text changes** in the input field. The logic is correct in this case, and it was a clever attempt. – rozsazoltan Jun 19 '23 at 09:15