์ž๋ฐ”์Šคํฌ๋ฆฝํŠธ๋ฅผ ํ™œ์„ฑํ™” ํ•ด์ฃผ์„ธ์š”

Vue 3 - Implement simple tooltip

 ·   ·  โ˜• 4 min read  ·  โœ๏ธ Yogo

Vue 3 - ๊ฐ„๋‹จํ•œ ํˆดํŒ ๊ธฐ๋Šฅ ๊ตฌํ˜„

Vuetify๋ฅผ ์‚ฌ์šฉํ•˜๋‹ค ๋ณด๋ฉด ๋ณต์žกํ•œ ๋ ˆ์ด์•„์›ƒ์œผ๋กœ ๊ตฌ์„ฑ๋œ ํ™”๋ฉด์ด๋‚˜ content๊ฐ€ ๋งŽ์€ ์ฆ‰, ์Šคํฌ๋กคํ•ด์•ผ ํ•˜๋Š” ํ™”๋ฉด์—์„œ v-menu, v-tooltip ๊ฐ™์ด ๋ณ„๋„ hidden content๊ฐ€ ์ด๋ฒคํŠธ(hover)์— ๋”ฐ๋ผ show/hide ๋˜๋Š” ์ปดํฌ๋„ŒํŠธ์˜ ๊ฒฝ์šฐ ์ œ๋Œ€๋กœ ํ‘œ์‹œ๋˜์ง€ ์•Š๋Š” ๋ฌธ์ œ๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค.

์ด๋Ÿด๋•Œ ‘attach’ property๋กœ ์ผ๋ถ€ ํ•ด๊ฒฐ์ด ๋˜๋Š” ๊ฒฝ์šฐ๋„ ์žˆ์ง€๋งŒ ๊ทธ๋ ‡์ง€ ์•Š์€ ๊ฒฝ์šฐ๋„ ์žˆ์–ด์„œ ๋Œ€์•ˆ์œผ๋กœ ๋‹ค๋ฅธ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์“ฐ๊ฑฐ๋‚˜ ํ•„์š” ์‹œ ์ง์ ‘ ๊ตฌํ˜„์„ ํ•˜๊ธฐ๋„ ํ•˜๋Š”๋ฐ ์‹ฌํ”Œํ•˜๊ฒŒ ๋™์ž‘ํ•˜๋Š” tooltip์„ ํ•œ๋ฒˆ ๋งŒ๋“ค์–ด ๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค.

๋จผ์ € vue component์—์„œ template ์˜์—ญ์ž…๋‹ˆ๋‹ค.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<template>
  <div class="tooltip-container">
    <div ref="targetEl" @mouseover="showContent(true)" @mouseout="showContent(false)">
      <slot></slot>
    </div>
    <div
      ref="contentEl"
      class="tooltip-content"
      :style="{
        opacity: opacity,
        top: `${contentTop}px`,
        left: `${contentLeft}px`,
        'font-size': `${fontSize}px`,
      }"
    >
      {{ tooltip }}
    </div>
  </div>
</template>


๋งˆ์šฐ์Šค ์ด๋ฒคํŠธ๋ฅผ ๋ฐœ์ƒ์‹œํ‚ฌ ํƒ€๊ฒŸ์ด ๋˜๋Š” slot์˜์—ญ์„ ๊ฐ์‹ธ๋Š” target element์™€ tooltip content๊ฐ€ ํ‘œ์‹œ๋˜๋Š” content element๋ฅผ ๊ตฌ๋ถ„ํ•˜๊ณ  ์ด๋ฅผ ๊ฐ์‹ธ๋Š” container๋กœ ๊ตฌ์„ฑ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ target element ๋งˆ์šฐ์Šค ์ด๋ฒคํŠธ์— ๋”ฐ๋ผ tooltip์˜ visibility๋ฅผ ์กฐ์ ˆํ•˜๋Š”๋ฐ, ์ด๋•Œ v-show (display is none), v-if (not mounted)๋กœ ์ œ์–ดํ•˜๋Š” ๋ฐฉ๋ฒ•, ๋‹ค๋ฅธ ๋ฐฉ๋ฒ•์œผ๋กœ๋Š” style์˜ display๋‚˜ opacity ๊ฐ’์œผ๋กœ ์ œ์–ด๋ฅผ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋Š” opacity๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ํˆฌ๋ช…๋„๋ฅผ ์กฐ์ ˆํ•ด์„œ ๋ณด์ด๊ณ  ์‚ฌ๋ผ์ง€๋Š” ๋ฐฉ์‹์„ ํƒํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

์ด์œ ๋Š” tooltip ์œ„์น˜๋ฅผ target ์œ„์น˜๋ฅผ ๊ธฐ์ค€์œผ๋กœ ๊ฒฐ์ •ํ•ด์•ผ ํ•˜๋Š”๋ฐ ํŠนํžˆ, top์ด๋‚˜ left ์œ„์น˜์— ํ‘œ์‹œํ•˜๋Š” ๊ฒฝ์šฐ traget element์™€ content element๊ฐ€ ๊ฒน์น˜์ง€ ์•Š๋„๋ก ํ•˜๊ธฐ ์œ„ํ•ด์„œ content element์˜ width๋‚˜ height ๋งŒํผ offset ์ฐธ์กฐ๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ๋งŒ์•ฝ content element๊ฐ€ rendered ์ƒํƒœ๊ฐ€ ์•„๋‹๋•Œ ํ•ด๋‹น ๊ฐ’์„ ์ฝ์„ ๊ฒฝ์šฐ์—๋Š” ์œ„์น˜๋‚˜ ํฌ๊ธฐ ๊ฐ’์ด ์ œ๋Œ€๋กœ ๋ฐ˜ํ™˜์ด ๋˜์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์—, vue component๊ฐ€ mounted ๋œ ์‹œ์ ์— content element๋„ rendered ๋œ ์ƒํƒœ๊ฐ€ ๋  ์ˆ˜ ์žˆ๋„๋ก display ์ƒํƒœ๊ฐ€ ๋ณ€๊ฒฝ๋˜์ง€ ์•Š๋„๋ก ํ•ฉ๋‹ˆ๋‹ค.

๋งŒ์•ฝ opacity ๋Œ€์‹  ๋‹ค๋ฅธ ๋ฐฉ์‹์„ ์‚ฌ์šฉํ•˜๊ณ ์ž ํ•  ๋•Œ์—๋Š” vue component์—์„œ updated ์ด๋ฒคํŠธ ์‹œ content element์˜ rendered ์ƒํƒœ๋ฅผ ํ™•์ธ ํ›„ ์œ„์น˜ ๊ฐ’์„ ๊ณ„์‚ฐํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ์“ธ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// Vue 3, Composition API, Typescript
<script lang="ts">
import { defineComponent, onMounted, ref, watch } from 'vue';

export default defineComponent({
  name: 'Tooltip',
  props: {
    top: Boolean,
    left: Boolean,
    right: Boolean,
    bottom: {
      type: Boolean,
      default: true,
    },
    tooltip: String,
    fontSize: {
      type: String,
      default: '12px',
    },
    margin: {
      type: Number,
      default: 8,
    },
  },
  setup(props) {
    const contentTop = ref(0);
    const contentLeft = ref(0);
    const opacity = ref(0);
    const contentisible = ref(false);
    const targetEl = ref(null);
    const contentEl = ref(null);

    const showContent = (visible: boolean) => {
      opacity.value = visible ? 1 : 0;
      if (contentEl.value && targetEl.value) {
        const { top, left, right, bottom } = (targetEl.value! as HTMLElement).getBoundingClientRect();
        const { width, height } = (targetEl.value! as HTMLElement).getBoundingClientRect();
        const contentRect = (contentEl.value! as HTMLElement).getBoundingClientRect();
        if (props.top) {
          contentTop.value = top - (contentRect.height + props.margin);
          contentLeft.value = left;
        } else if (props.left) {
          contentTop.value = top;
          contentLeft.value = left - (contentRect.width + props.margin);
        } else if (props.right) {
          contentTop.value = top;
          contentLeft.value = right + props.margin;
        } else {
          contentTop.value = bottom + props.margin;
          contentLeft.value = left;
        }
      }
    };

    return {
      contentTop,
      contentLeft,
      opacity,
      targetEl,
      contentEl,
      showContent,
    };
  },
});
</script>


props์— ํˆดํŒ์ด ์œ„์น˜ํ•  ๊ณณ์„ ๊ฒฐ์ •ํ•˜๋Š” top, left, right, bottom๋ฅผ ๊ฐ€์ง‘๋‹ˆ๋‹ค. ๊ทธ ์™ธ content์˜ font-size๋‚˜ margin์„ ๋ณ„๋„๋กœ ์กฐ์ ˆํ•  ์ˆ˜ ์žˆ๋„๋ก ํ–ˆ์Šต๋‹ˆ๋‹ค.

๊ธฐ๋ณธ์ ์ธ ๋™์ž‘์ธ target์— mouseover ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒ ์‹œ target element์˜ ์œ„์น˜์™€ content element์˜ ํฌ๊ธฐ ๊ฐ’์„ ๊ฐ€์ ธ์™€์„œ ์œ„์น˜๋ฅผ ๊ณ„์‚ฐํ•˜๊ณ  ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค.

์œ„์น˜ ๊ณ„์‚ฐ์˜ ๊ฒฝ์šฐ rendered ๋œ ์‹œ์ ์—์„œ ํ•œ๋ฒˆ๋งŒ ํ•ด๋„ ๋ฉ๋‹ˆ๋‹ค. ๋‹ค๋งŒ ๋ธŒ๋ผ์šฐ์ ธ ๋‚ด๋ถ€ window ํฌ๊ธฐ๊ฐ€ ๋ณ€๊ฒฝ๋˜๋Š” ๊ฒฝ์šฐ target์˜ ์œ„์น˜๋‚˜ content element ํฌ๊ธฐ๋„ ๋ณ€๊ฒฝ์ด ๋  ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ๊ฐฑ์‹ ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฅผ ์œ„ํ•ด์„œ window resize ์ด๋ฒคํŠธ ์‹œ ๊ฐฑ์‹ ์„ ์ˆ˜ํ–‰ํ•˜๋ฉด ๋˜๋Š”๋ฐ window ํฌ๊ธฐ๊ฐ€ ์กฐ๊ธˆ๋งŒ ๋ณ€๊ฒฝ๋˜๋”๋ผ๋„ resize ์ด๋ฒคํŠธ๊ฐ€ ์ˆ˜์‹ญ ~ ์ˆ˜๋ฐฑ๋ฒˆ ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ•˜๊ธฐ ๋•Œ๋ฌธ์—, ์ด๋ฒคํŠธ๋ฅผ debouncing์ด๋‚˜ throttling ๋˜๋„๋ก ํ•˜์—ฌ ์ค‘๋ณต์„ ์ตœ๋Œ€ํ•œ ์ค„์—ฌ์ฃผ๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ์‹ค์ œ๋กœ tootip์„ ์ ์šฉํ•œ๋‹ค๊ณ  ํ•  ์‹œ tooltip๋ฅผ ํ‘œ์‹œํ•˜๋Š” ์ด๋ฒคํŠธ๋Š” ์ƒ๋Œ€์ ์œผ๋กœ ๋งŽ์ง€ ์•Š์„ ๊ฒƒ์ด๋ฏ€๋กœ ์ค‘๋ณต์—ฌ๋ถ€ ์ƒ๊ด€์—†์ด ๋งค๋ฒˆ ๊ณ„์‚ฐํ•˜๋Š” ๊ฒƒ์œผ๋กœ ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<style scoped>
.tooltip-container {
  display: inline-block;
}

.tooltip-content {
  position: absolute;
  font-size: 12px;
  background: #fefefe;
  color: #555;
  box-shadow: 0 0 6px 1px rgba(0, 0, 0, 0.15);
  padding: 4px 8px;
  border-radius: 4px;
  pointer-events: none;
  transition: opacity 0.4s;
}
</style>

์Šคํƒ€์ผ ์ง€์ •์€ ํŠน๋ณ„ํ•œ ๊ฒƒ์€ ์—†๊ณ  slot์— ๋งž์ถ”๊ธฐ ์œ„ํ•ด์„œ container์˜ display๋ฅผ inline-block์œผ๋กœ ์ง€์ •ํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค๋ฅธ ์œ ํ˜•์œผ๋กœ ์„ ํƒํ•˜๋Š” ๊ฒฝ์šฐ ํ‘œ์‹œ๋˜๋Š” ์œ„์น˜๊ฐ€ ๋‹ฌ๋ผ์งˆ ์ˆ˜ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ์ด ๊ฒฝ์šฐ tooltip-content์˜ position์„ fixed๋กœ ์„ ํƒํ•ด์•ผ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. transition์— opacity๋ฅผ ์ง€์ •ํ•˜์˜€๊ธฐ ๋•Œ๋ฌธ์— fade in/out ํšจ๊ณผ๊ฐ€ ์ ์šฉ๋˜์–ด ํ‘œ์‹œ๋˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<template>
  <div class="tooltip">
    <h2>Tooltip</h2>
    <p>
      <vTooltip tooltip="left message" left>
        Left
      </vTooltip>
    </p>

    <div>
      <vTooltip tooltip="top message" top>
        <p>Top</p>
      </vTooltip>
    </div>

    <vTooltip tooltip="right message" right>
      <p>Right</p>
    </vTooltip>
    <br />
    <vTooltip tooltip="bottom message" bottom>
      <p>Bottom</p>
    </vTooltip>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue';
import vTooltip from '@/components/Tooltip.vue'; // @ is an alias to /src

export default defineComponent({
  components: {
    vTooltip,
  },
});
</script>

๊ทธ๋Ÿผ ๊ฐ„๋‹จํ•œ ํ…Œ์ŠคํŠธ ํŽ˜์ด์ง€๋ฅผ ์ž‘์„ฑํ•ด๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค.

ํƒ€๊ฒŸ์ด ๋˜๋Š” ํƒœ๊ทธ ์ž์ฒด๋ฅผ ๊ฐ์‹ธ๊ฑฐ๋‚˜ ํƒœ๊ทธ ๋‚ด์˜ content๋งŒ ํƒ€๊ฒŸ์œผ๋กœ ํ•˜์—ฌ ๊ฐ„๋‹จํ•˜๊ฒŒ tooltip ๋ฉ”์‹œ์ง€๋ฅผ ํ‘œ์‹œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์ด๋ฆ„์˜ ๊ฒฝ์šฐ vTooltip์œผ๋กœ ์ง€์ •ํ–ˆ๋Š”๋ฐ ์ตœ๊ทผ vue 3 ์—…๋ฐ์ดํŠธ ๋ณ€๊ฒฝ์ ์ด ์žˆ์—ˆ๋Š”์ง€ tooltip(๋Œ€์†Œ๋ฌธ์ž ๊ตฌ๋ถ„์—†์ด)์˜ ์ด๋ฆ„์„ ๊ทธ๋Œ€๋กœ ์“ฐ๋Š” ๊ฒฝ์šฐ ์•Œ ์ˆ˜ ์—†๋Š” ์ด์œ ๋กœ ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ•˜๊ธฐ ๋•Œ๋ฌธ์— (์ด์ „์— ๋ฐœ์ƒํ•˜์ง€ ์•Š๋˜ ์—๋Ÿฌ๋ผ ์ถ”๊ฐ€ ํ™•์ธ์ด ํ•„์š”) ๋ถ€๋“์ดํ•˜๊ฒŒ ์ ‘๋‘์–ด๋ฅผ ๋ถ™์˜€์Šต๋‹ˆ๋‹ค.

์•„๋ž˜ gif ์ด๋ฏธ์ง€๋ฅผ ๋ณด๋ฉด ๋™์ž‘ํ•˜๋Š” ํˆดํŒ ์˜ˆ์ œ๋ฅผ ํ™•์ธ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

tootip example