|
1 | 1 | <script lang="ts"> |
2 | 2 | const tooltipProps = { |
| 3 | + tag: { |
| 4 | + type: String, |
| 5 | + default: 'div', |
| 6 | + }, |
3 | 7 | tooltip: String, |
4 | | - placement: { |
5 | | - type: String as PropType<PopoverPlacement>, |
| 8 | + disabled: Boolean, |
| 9 | + position: { |
| 10 | + type: String as PropType<TooltipPosition>, |
6 | 11 | default: 'bottom', |
7 | 12 | }, |
8 | 13 | } |
9 | 14 |
|
10 | | -export type PopoverPlacement = Placement |
| 15 | +export type TooltipPosition = 'top' | 'bottom' | 'left' | 'right' |
11 | 16 |
|
12 | 17 | export type TooltipProps = ExtractPublicPropTypes<typeof tooltipProps> |
13 | 18 |
|
| 19 | +type InternalClasses = 'tooltip' |
| 20 | +export interface TooltipTheme extends ThemeComponent<TooltipProps, InternalClasses> {} |
| 21 | +
|
14 | 22 | export default { name: 'XTooltip' } |
15 | 23 | </script> |
16 | 24 |
|
17 | 25 | <script setup lang="ts"> |
| 26 | +import { computed, ref, useSlots } from 'vue' |
18 | 27 | import type { ExtractPublicPropTypes, PropType } from 'vue' |
19 | | -import XPopover from '../../components/popover/Popover.vue' |
20 | | -import XPopoverContainer from '../../components/popover/PopoverContainer.vue' |
21 | | -import { type Placement } from 'floating-vue' |
| 28 | +import { useTheme, type ThemeComponent } from '../../composables/useTheme' |
| 29 | +
|
| 30 | +const props = defineProps(tooltipProps) |
| 31 | +
|
| 32 | +const triggerRef = ref<HTMLElement | null>(null) |
| 33 | +const tooltipRef = ref<HTMLElement | null>(null) |
| 34 | +const isVisible = ref(false) |
| 35 | +const tooltipStyle = ref<Record<string, string>>({}) |
| 36 | +const actualPosition = ref<TooltipPosition>(props.position) |
| 37 | +
|
| 38 | +const slots = useSlots() |
| 39 | +const hasTooltip = computed(() => props.tooltip || slots.tooltip) |
| 40 | +
|
| 41 | +const computedDisabled = computed(() => props.disabled || !hasTooltip.value) |
| 42 | +
|
| 43 | +const arrowPositionClasses = computed(() => { |
| 44 | + const placements = { |
| 45 | + top: '-bottom-2.5 left-1/2 -translate-x-1/2 w-3.5', |
| 46 | + bottom: '-top-2.5 left-1/2 -translate-x-1/2 w-3.5', |
| 47 | + left: '-right-2.5 top-1/2 -translate-y-1/2 h-3.5', |
| 48 | + right: '-left-2.5 top-1/2 -translate-y-1/2 h-3.5', |
| 49 | + } |
| 50 | +
|
| 51 | + return placements[actualPosition.value] |
| 52 | +}) |
| 53 | +
|
| 54 | +const arrowRotationClasses = computed(() => { |
| 55 | + const placements = { |
| 56 | + top: '-rotate-45 origin-top-left', |
| 57 | + bottom: 'rotate-45 origin-bottom-left', |
| 58 | + left: 'rotate-45 origin-top-left', |
| 59 | + right: '-rotate-45 origin-top-right', |
| 60 | + } |
| 61 | +
|
| 62 | + return placements[actualPosition.value] |
| 63 | +}) |
| 64 | +
|
| 65 | +const animationOriginClasses = computed(() => { |
| 66 | + const origins = { |
| 67 | + top: 'origin-bottom', |
| 68 | + bottom: 'origin-top', |
| 69 | + left: 'origin-right', |
| 70 | + right: 'origin-left', |
| 71 | + } |
| 72 | +
|
| 73 | + return origins[actualPosition.value] |
| 74 | +}) |
| 75 | +
|
| 76 | +const calculatePosition = () => { |
| 77 | + if (!triggerRef.value || !tooltipRef.value) return |
22 | 78 |
|
23 | | -defineProps(tooltipProps) |
| 79 | + const triggerRect = triggerRef.value.getBoundingClientRect() |
| 80 | + const tooltipRect = tooltipRef.value.getBoundingClientRect() |
| 81 | + const spacing = 8 // 0.5rem = 8px |
| 82 | + const viewportWidth = window.innerWidth |
| 83 | + const viewportHeight = window.innerHeight |
| 84 | +
|
| 85 | + let position = props.position |
| 86 | + let top = 0 |
| 87 | + let left = 0 |
| 88 | +
|
| 89 | + // Calculate initial position based on placement |
| 90 | + switch (position) { |
| 91 | + case 'top': |
| 92 | + top = triggerRect.top - tooltipRect.height - spacing |
| 93 | + left = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2 |
| 94 | + // Check if tooltip goes off-screen top |
| 95 | + if (top < 0) { |
| 96 | + position = 'bottom' |
| 97 | + top = triggerRect.bottom + spacing |
| 98 | + } |
| 99 | + break |
| 100 | + case 'bottom': |
| 101 | + top = triggerRect.bottom + spacing |
| 102 | + left = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2 |
| 103 | + // Check if tooltip goes off-screen bottom |
| 104 | + if (top + tooltipRect.height > viewportHeight) { |
| 105 | + position = 'top' |
| 106 | + top = triggerRect.top - tooltipRect.height - spacing |
| 107 | + } |
| 108 | + break |
| 109 | + case 'left': |
| 110 | + top = triggerRect.top + triggerRect.height / 2 - tooltipRect.height / 2 |
| 111 | + left = triggerRect.left - tooltipRect.width - spacing |
| 112 | + // Check if tooltip goes off-screen left |
| 113 | + if (left < 0) { |
| 114 | + position = 'right' |
| 115 | + left = triggerRect.right + spacing |
| 116 | + } |
| 117 | + break |
| 118 | + case 'right': |
| 119 | + top = triggerRect.top + triggerRect.height / 2 - tooltipRect.height / 2 |
| 120 | + left = triggerRect.right + spacing |
| 121 | + // Check if tooltip goes off-screen right |
| 122 | + if (left + tooltipRect.width > viewportWidth) { |
| 123 | + position = 'left' |
| 124 | + left = triggerRect.left - tooltipRect.width - spacing |
| 125 | + } |
| 126 | + break |
| 127 | + } |
| 128 | +
|
| 129 | + // Adjust horizontal position to stay within viewport |
| 130 | + if (position === 'top' || position === 'bottom') { |
| 131 | + if (left < 0) left = spacing |
| 132 | + if (left + tooltipRect.width > viewportWidth) { |
| 133 | + left = viewportWidth - tooltipRect.width - spacing |
| 134 | + } |
| 135 | + } |
| 136 | +
|
| 137 | + // Adjust vertical position to stay within viewport |
| 138 | + if (position === 'left' || position === 'right') { |
| 139 | + if (top < 0) top = spacing |
| 140 | + if (top + tooltipRect.height > viewportHeight) { |
| 141 | + top = viewportHeight - tooltipRect.height - spacing |
| 142 | + } |
| 143 | + } |
| 144 | +
|
| 145 | + actualPosition.value = position |
| 146 | + tooltipStyle.value = { |
| 147 | + top: `${top}px`, |
| 148 | + left: `${left}px`, |
| 149 | + } |
| 150 | +} |
| 151 | +
|
| 152 | +const showTooltip = () => { |
| 153 | + if (computedDisabled.value) return |
| 154 | + isVisible.value = true |
| 155 | + setTimeout(calculatePosition, 0) |
| 156 | +} |
| 157 | +
|
| 158 | +const hideTooltip = () => { |
| 159 | + if (computedDisabled.value) return |
| 160 | + isVisible.value = false |
| 161 | +} |
| 162 | +
|
| 163 | +const { classes, className } = useTheme('Tooltip', {}, props) |
24 | 164 | </script> |
25 | 165 |
|
26 | 166 | <template> |
27 | | - <x-popover :triggers="['hover', 'click']" class="inline-block" :hide-arrow="false" :placement="placement"> |
| 167 | + <component |
| 168 | + :is="tag" |
| 169 | + ref="triggerRef" |
| 170 | + :class="[ |
| 171 | + className, |
| 172 | + { |
| 173 | + 'inline-block': !$attrs.class, |
| 174 | + }]" |
| 175 | + @mouseenter="showTooltip" |
| 176 | + @mouseleave="hideTooltip" |
| 177 | + > |
28 | 178 | <slot></slot> |
29 | | - <template #content> |
30 | | - <div class="dark"> |
31 | | - <x-popover-container class="p-2 text-white text-xs w-max max-w-xs"> |
32 | | - <slot name="tooltip">{{ tooltip }}</slot> |
33 | | - </x-popover-container> |
| 179 | + </component> |
| 180 | + |
| 181 | + <teleport v-if="!computedDisabled" to="body"> |
| 182 | + <transition |
| 183 | + enter-active-class="transition-opacity duration-150 ease-out" |
| 184 | + leave-active-class="transition-opacity duration-150 ease-in" |
| 185 | + enter-from-class="opacity-0" |
| 186 | + enter-to-class="opacity-100" |
| 187 | + leave-from-class="opacity-100" |
| 188 | + leave-to-class="opacity-0" |
| 189 | + > |
| 190 | + <div |
| 191 | + v-if="isVisible" |
| 192 | + ref="tooltipRef" |
| 193 | + :style="tooltipStyle" |
| 194 | + class="fixed z-[9999] pointer-events-none" |
| 195 | + :class="[ |
| 196 | + classes.tooltip, |
| 197 | + animationOriginClasses |
| 198 | + ]" |
| 199 | + > |
| 200 | + <slot name="tooltip">{{ tooltip }}</slot> |
| 201 | + <div :class="['absolute overflow-hidden shadow-lg z-10', arrowPositionClasses]"> |
| 202 | + <div :class="['h-2.5 w-2.5 bg-secondary-700 transform border border-secondary-800', arrowRotationClasses]"></div> |
| 203 | + </div> |
34 | 204 | </div> |
35 | | - </template> |
36 | | - </x-popover> |
| 205 | + </transition> |
| 206 | + </teleport> |
37 | 207 | </template> |
0 commit comments