|
| 1 | +<script setup lang="ts"> |
| 2 | +import { computed, defineProps, ref, watch, onUnmounted, onMounted } from 'vue' |
| 3 | +
|
| 4 | +import { getRandomId } from '../../utils/random-utils' |
| 5 | +
|
| 6 | +const props = withDefaults(defineProps<{ |
| 7 | + content: string |
| 8 | + onHover?: boolean |
| 9 | + id?: string, |
| 10 | + }>(), { |
| 11 | + id: () => getRandomId('tooltip'), |
| 12 | +}) |
| 13 | +
|
| 14 | +const show = ref(false) |
| 15 | +
|
| 16 | +const source = ref<HTMLElement | null>(null) |
| 17 | +const tooltip = ref<HTMLElement | null>(null) |
| 18 | +
|
| 19 | +const translateX = ref('0px') |
| 20 | +const translateY = ref('0px') |
| 21 | +const arrowX = ref('0px') |
| 22 | +const top = ref(false) |
| 23 | +const opacity = ref(0) |
| 24 | +
|
| 25 | +watch(show, async (value) => { |
| 26 | + if (typeof document === 'undefined') { |
| 27 | + return |
| 28 | + } |
| 29 | + if (!value) { |
| 30 | + return |
| 31 | + } |
| 32 | +
|
| 33 | + opacity.value = 0 |
| 34 | + await new Promise(resolve => setTimeout(resolve, 100)) |
| 35 | + const sourceTop = source.value?.offsetTop |
| 36 | + const sourceHeight = source.value?.offsetHeight |
| 37 | + const sourceWidth = source.value?.offsetWidth |
| 38 | + const sourceLeft = source.value?.offsetLeft |
| 39 | + const tooltipHeight = tooltip.value?.offsetHeight |
| 40 | + const tooltipWidth = tooltip.value?.offsetWidth |
| 41 | + const isSourceAtTop = (sourceTop - tooltipHeight) < 0 |
| 42 | + const isSourceAtBottom = !isSourceAtTop && (sourceTop + sourceHeight + tooltipHeight) >= document.documentElement.offsetHeight |
| 43 | + top.value = isSourceAtBottom |
| 44 | + const isSourceOnRightSide = (sourceLeft + sourceWidth) >= document.documentElement.offsetWidth |
| 45 | + const isSourceOnLeftSide = (sourceLeft + (sourceWidth / 2) - (tooltipWidth / 2)) <= 0 |
| 46 | +
|
| 47 | + translateY.value = isSourceAtBottom |
| 48 | + ? `${sourceTop - tooltipHeight + 8}px` |
| 49 | + : `${sourceTop + sourceHeight - 8}px` |
| 50 | + opacity.value = 1 |
| 51 | + translateX.value = isSourceOnRightSide |
| 52 | + ? `${sourceLeft + sourceWidth - tooltipWidth - 4}px` |
| 53 | + : isSourceOnLeftSide |
| 54 | + ? `${sourceLeft + 4}px` |
| 55 | + : `${sourceLeft + (sourceWidth / 2) - (tooltipWidth / 2)}px` |
| 56 | +
|
| 57 | + arrowX.value = isSourceOnRightSide |
| 58 | + ? `${(tooltipWidth / 2) - (sourceWidth / 2) + 4}px` |
| 59 | + : isSourceOnLeftSide |
| 60 | + ? `${-(tooltipWidth / 2) + (sourceWidth / 2) - 4}px` |
| 61 | + : '0px' |
| 62 | +}) |
| 63 | +
|
| 64 | +const tooltipStyle = computed(() => (`transform: translate(${translateX.value}, ${translateY.value}); --arrow-x: ${arrowX.value}; opacity: ${opacity.value};'`)) |
| 65 | +const tooltipClass = computed(() => ({ |
| 66 | + 'fr-tooltip--shown': show.value, |
| 67 | + 'fr-placement--top': top.value, |
| 68 | + 'fr-placement--bottom': !top.value, |
| 69 | +})) |
| 70 | +
|
| 71 | +const clickListener = (event: MouseEvent) => { |
| 72 | + if (!show.value) { |
| 73 | + return |
| 74 | + } |
| 75 | + if (event.target === source.value || source.value?.contains(event.target as Node)) { |
| 76 | + return |
| 77 | + } |
| 78 | + if (event.target === tooltip.value || tooltip.value?.contains(event.target as Node)) { |
| 79 | + return |
| 80 | + } |
| 81 | + show.value = false |
| 82 | +} |
| 83 | +
|
| 84 | +onMounted(() => { |
| 85 | + document.documentElement.addEventListener('click', clickListener) |
| 86 | +}) |
| 87 | +
|
| 88 | +onUnmounted(() => { |
| 89 | + document.documentElement.removeEventListener('click', clickListener) |
| 90 | +}) |
| 91 | +
|
| 92 | +const onMouseEnter = () => { |
| 93 | + if (props.onHover) { |
| 94 | + show.value = true |
| 95 | + } |
| 96 | +} |
| 97 | +
|
| 98 | +const onMouseLeave = () => { |
| 99 | + if (props.onHover) { |
| 100 | + show.value = false |
| 101 | + } |
| 102 | +} |
| 103 | +
|
| 104 | +const onClick = () => { |
| 105 | + if (!props.onHover) { |
| 106 | + show.value = !show.value |
| 107 | + } |
| 108 | +} |
| 109 | +</script> |
| 110 | + |
| 111 | +<template> |
| 112 | + <component |
| 113 | + :is="onHover ? 'a' : 'button'" |
| 114 | + :id="'link-' + id" |
| 115 | + ref="source" |
| 116 | + :class="onHover ? 'fr-link' : 'fr-btn fr-btn--tooltip'" |
| 117 | + :aria-describedby="id" |
| 118 | + :href="onHover ? '#' : undefined" |
| 119 | + @click="onClick()" |
| 120 | + @mouseenter="onMouseEnter()" |
| 121 | + @mouseleave="onMouseLeave()" |
| 122 | + > |
| 123 | + <slot /> |
| 124 | + </component> |
| 125 | + <span |
| 126 | + :id="id" |
| 127 | + ref="tooltip" |
| 128 | + class="fr-tooltip fr-placement" |
| 129 | + :class="tooltipClass" |
| 130 | + :style="tooltipStyle" |
| 131 | + role="tooltip" |
| 132 | + aria-hidden="true" |
| 133 | + > |
| 134 | + {{ content }} |
| 135 | + </span> |
| 136 | +</template> |
| 137 | + |
| 138 | +<style scoped> |
| 139 | +.fr-tooltip { |
| 140 | + transition: opacity 0.3s ease-in-out; |
| 141 | +} |
| 142 | +</style> |
0 commit comments