423 lines
14 KiB
Vue
423 lines
14 KiB
Vue
<script setup lang="ts">
|
|
interface Props {
|
|
type?: 'click' | 'hover'
|
|
position?:
|
|
| 'top'
|
|
| 'bottom'
|
|
| 'left'
|
|
| 'right'
|
|
| 'top-left'
|
|
| 'top-right'
|
|
| 'bottom-left'
|
|
| 'bottom-right'
|
|
offset?: number
|
|
backgroundColor?: string
|
|
textColor?: string
|
|
textAlign?: string
|
|
fontSize?: string
|
|
fontWeight?: string
|
|
lineHeight?: string
|
|
letterSpacing?: string
|
|
arrow?: boolean
|
|
teleport?: boolean
|
|
content?: string
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
type: 'click',
|
|
position: 'top',
|
|
offset: 8,
|
|
arrow: true,
|
|
teleport: false,
|
|
backgroundColor: '#666666',
|
|
textColor: '#FFFFFF',
|
|
textAlign: 'center',
|
|
fontSize: '12px',
|
|
fontWeight: '400',
|
|
lineHeight: '18px',
|
|
letterSpacing: '-0.24px',
|
|
})
|
|
|
|
const isOpen = defineModel<boolean>('isOpen', { default: false })
|
|
|
|
const isLeftAligned = ref(false) // 브라우저 화면 기준으로 툴팁의 왼쪽 가장자리가 화면 밖으로 나가는 경우 좌측 정렬을 합니다.
|
|
const isRightAligned = ref(false) // 브라우저 화면 기준으로 툴팁의 오른쪽 가장자리가 화면 밖으로 나가는 경우 우측 정렬을 합니다.
|
|
const tooltipOffsetX = ref(18) // 툴팁 오프셋 X 값
|
|
const tooltipArrowOffsetX = ref(26) // 툴팁 화살표 오프셋 X 값
|
|
const tooltipPosition = ref({
|
|
// 툴팁 위치 값
|
|
top: null,
|
|
bottom: null,
|
|
left: null,
|
|
right: null,
|
|
})
|
|
const tooltipTriggerRef = ref<HTMLElement | null>(null)
|
|
|
|
const isPositions = computed(() => {
|
|
return {
|
|
isTop:
|
|
props.position === 'top' && !isLeftAligned.value && !isRightAligned.value,
|
|
isTopLeft:
|
|
props.position === 'top-left' ||
|
|
(props.position === 'top' && isRightAligned.value),
|
|
isTopRight:
|
|
props.position === 'top-right' ||
|
|
(props.position === 'top' && isLeftAligned.value),
|
|
isBottom:
|
|
props.position === 'bottom' &&
|
|
!isLeftAligned.value &&
|
|
!isRightAligned.value,
|
|
isBottomLeft:
|
|
props.position === 'bottom-left' ||
|
|
(props.position === 'bottom' && isRightAligned.value),
|
|
isBottomRight:
|
|
props.position === 'bottom-right' ||
|
|
(props.position === 'bottom' && isLeftAligned.value),
|
|
isLeft: props.position === 'left',
|
|
isRight: props.position === 'right',
|
|
}
|
|
})
|
|
|
|
/**
|
|
* 툴팁 초기화 함수입니다.
|
|
*/
|
|
const resetTooltipContent = () => {
|
|
tooltipPosition.value.top = null
|
|
tooltipPosition.value.bottom = null
|
|
tooltipPosition.value.left = null
|
|
tooltipPosition.value.right = null
|
|
isOpen.value = false
|
|
isLeftAligned.value = false
|
|
isRightAligned.value = false
|
|
tooltipTriggerRef.value = null
|
|
}
|
|
|
|
/**
|
|
* 반응형 툴팁 위치 계산 함수입니다.
|
|
* @param trigger 트리거 요소
|
|
*/
|
|
const calculateTooltipPosition = (trigger: HTMLElement) => {
|
|
const triggerRect = trigger.getBoundingClientRect()
|
|
const tooltipOffset = props.offset + 4 // 툴팁 오프셋
|
|
const triggerCenterX = triggerRect.left + triggerRect.width / 2
|
|
const triggerCenterY = triggerRect.top + triggerRect.height / 2
|
|
|
|
const isLefts =
|
|
isPositions.value.isLeft ||
|
|
isPositions.value.isTopLeft ||
|
|
isPositions.value.isBottomLeft ||
|
|
isLeftAligned.value
|
|
const isOnlyLeft =
|
|
isPositions.value.isLeft &&
|
|
Object.values(isPositions.value).filter(Boolean).length === 1
|
|
|
|
const isRights =
|
|
isPositions.value.isRight ||
|
|
isPositions.value.isTopRight ||
|
|
isPositions.value.isBottomRight ||
|
|
isRightAligned.value
|
|
const isOnlyRight =
|
|
isPositions.value.isRight &&
|
|
Object.values(isPositions.value).filter(Boolean).length === 1
|
|
|
|
let topPosition: number | null = null
|
|
let bottomPosition: number | null = null
|
|
let leftPosition: number | null = null
|
|
let rightPosition: number | null = null
|
|
|
|
if (isLefts) {
|
|
if (isOnlyLeft) {
|
|
leftPosition = triggerRect.left - tooltipOffset
|
|
} else {
|
|
leftPosition = triggerRect.left + triggerRect.width + tooltipOffsetX.value
|
|
}
|
|
} else if (isRights) {
|
|
if (isOnlyRight) {
|
|
leftPosition = triggerRect.left + triggerRect.width + tooltipOffset
|
|
} else {
|
|
leftPosition = triggerRect.left - tooltipOffsetX.value
|
|
}
|
|
} else {
|
|
leftPosition = triggerCenterX
|
|
}
|
|
|
|
if (
|
|
isPositions.value.isBottom ||
|
|
isPositions.value.isBottomLeft ||
|
|
isPositions.value.isBottomRight
|
|
) {
|
|
topPosition = triggerRect.top
|
|
} else if (
|
|
isPositions.value.isTop ||
|
|
isPositions.value.isTopLeft ||
|
|
isPositions.value.isTopRight
|
|
) {
|
|
topPosition = triggerRect.top - tooltipOffset
|
|
} else {
|
|
topPosition = triggerCenterY
|
|
}
|
|
|
|
tooltipPosition.value = {
|
|
top: topPosition,
|
|
bottom: bottomPosition,
|
|
left: leftPosition,
|
|
right: rightPosition,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 좌측 정렬이 필요한지 확인 함수입니다.
|
|
* 브라우저 화면 기준으로 툴팁의 왼쪽 가장자리가 화면 밖으로 나가는 경우 좌측 정렬을 합니다.
|
|
*/
|
|
const isValidLeftAligned = (trigger: HTMLElement): boolean => {
|
|
const tooltipWidth = 280 // 툴팁 최대 너비
|
|
const triggerRect = trigger.getBoundingClientRect()
|
|
const tooltipOffset = props.offset + 4 // 툴팁 오프셋
|
|
|
|
// trigger의 중앙 위치 계산
|
|
const triggerCenterX = triggerRect.left + triggerRect.width / 2
|
|
|
|
// 중앙 정렬 시 툴팁의 왼쪽 가장자리 위치
|
|
const tooltipLeftEdge = triggerCenterX - tooltipWidth / 2
|
|
|
|
return tooltipLeftEdge < tooltipOffset
|
|
}
|
|
|
|
/**
|
|
* 우측 정렬이 필요한지 확인 함수입니다.
|
|
* 브라우저 화면 기준으로 툴팁의 오른쪽 가장자리가 화면 밖으로 나가는 경우 우측 정렬을 합니다.
|
|
*/
|
|
const isValidRightAligned = (trigger: HTMLElement): boolean => {
|
|
const tooltipWidth = 280 // 툴팁 최대 너비
|
|
const triggerRect = trigger.getBoundingClientRect()
|
|
const windowWidth = window.innerWidth
|
|
const tooltipOffset = props.offset + 4 // 툴팁 오프셋
|
|
|
|
// trigger의 중앙 위치 계산
|
|
const triggerCenterX = triggerRect.left + triggerRect.width / 2
|
|
|
|
// 중앙 정렬 시 툴팁의 오른쪽 가장자리 위치
|
|
const tooltipRightEdge = triggerCenterX + tooltipWidth / 2
|
|
|
|
return tooltipRightEdge > windowWidth - tooltipOffset
|
|
}
|
|
|
|
/**
|
|
* 툴팁 표시 함수입니다.
|
|
* @param event 이벤트 객체
|
|
* @param text 툴팁 텍스트
|
|
*/
|
|
const handleTooltip = (event: MouseEvent, type: string) => {
|
|
if (type !== props.type) {
|
|
return
|
|
}
|
|
|
|
const trigger = (event.currentTarget || event.target) as HTMLElement
|
|
|
|
isOpen.value = !isOpen.value
|
|
|
|
if (isOpen.value) {
|
|
tooltipTriggerRef.value = trigger
|
|
isLeftAligned.value = isValidLeftAligned(trigger)
|
|
isRightAligned.value = isValidRightAligned(trigger)
|
|
calculateTooltipPosition(trigger)
|
|
} else {
|
|
resetTooltipContent()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 브라우저 리사이즈 시 툴팁 위치 재계산 함수입니다.
|
|
*/
|
|
const handleResize = () => {
|
|
if (isOpen.value && tooltipTriggerRef.value) {
|
|
isLeftAligned.value = isValidLeftAligned(tooltipTriggerRef.value)
|
|
isRightAligned.value = isValidRightAligned(tooltipTriggerRef.value)
|
|
calculateTooltipPosition(tooltipTriggerRef.value)
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
window.addEventListener('resize', handleResize)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('resize', handleResize)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
class="relative z-[100] inline-flex items-center justify-center"
|
|
v-bind="$attrs"
|
|
>
|
|
<button
|
|
type="button"
|
|
@click="handleTooltip($event, 'click')"
|
|
@mouseenter="handleTooltip($event, 'hover')"
|
|
@mouseleave="handleTooltip($event, 'hover')"
|
|
>
|
|
<slot name="trigger" />
|
|
|
|
<template v-if="!$slots.trigger">
|
|
<AtomsIconsStateInfoCircleLine :size="20" color="#7F7F7F" />
|
|
</template>
|
|
</button>
|
|
<div
|
|
v-if="!teleport && isOpen"
|
|
:class="[
|
|
'absolute z-[10] flex items-center justify-center w-max max-w-[280px] py-[8px] px-[12px] rounded-[4px]',
|
|
]"
|
|
:style="[
|
|
isPositions.isTop
|
|
? `top: -${props.offset + 4}px; left: 50%; transform: translate(-50%, -100%)`
|
|
: isPositions.isBottom
|
|
? `bottom: -${props.offset + 4}px; left: 50%; transform: translate(-50%, 100%)`
|
|
: isPositions.isLeft
|
|
? `top: 50%; left: -${props.offset + 4}px; transform: translate(-100%, -50%)`
|
|
: isPositions.isRight
|
|
? `top: 50%; right: -${props.offset + 4}px; transform: translate(100%, -50%)`
|
|
: isPositions.isTopLeft
|
|
? `top: -${props.offset + 4}px; right: -${tooltipOffsetX}px; transform: translate(0, -100%)`
|
|
: isPositions.isTopRight
|
|
? `top: -${props.offset + 4}px; left: -${tooltipOffsetX}px; transform: translate(0, -100%)`
|
|
: isPositions.isBottomLeft
|
|
? `bottom: -${props.offset + 4}px; right: -${tooltipOffsetX}px; transform: translate(0, 100%)`
|
|
: isPositions.isBottomRight
|
|
? `bottom: -${props.offset + 4}px; left: -${tooltipOffsetX}px; transform: translate(0, 100%)`
|
|
: '',
|
|
`background-color: ${backgroundColor};`,
|
|
]"
|
|
>
|
|
<slot name="panel" />
|
|
|
|
<p
|
|
v-if="!$slots.panel"
|
|
v-dompurify-html="props.content"
|
|
:class="[
|
|
`relative flex items-center justify-center text-${props.textAlign} text-[${props.fontSize}] font-[${props.fontWeight}] leading-[${props.lineHeight}] tracking-[${props.letterSpacing}]`,
|
|
]"
|
|
:style="[`color: ${props.textColor};`]"
|
|
></p>
|
|
|
|
<span
|
|
v-if="arrow"
|
|
class="absolute"
|
|
:style="[
|
|
isPositions.isTop
|
|
? `bottom: -4px; left: 50%; transform: translate(-50%, 0)`
|
|
: isPositions.isBottom
|
|
? `top: -4px; left: 50%; transform: translate(-50%, 0) rotate(180deg)`
|
|
: isPositions.isLeft
|
|
? `right: -4px; top: 50%; transform: translate(0, -50%) rotate(-90deg)`
|
|
: isPositions.isRight
|
|
? `left: -4px; top: 50%; transform: translate(0, -50%) rotate(90deg)`
|
|
: isPositions.isTopLeft
|
|
? `bottom: -4px; right: ${tooltipArrowOffsetX}px; transform: translate(0, 0)`
|
|
: isPositions.isTopRight
|
|
? `bottom: -4px; left: ${tooltipArrowOffsetX}px; transform: translate(0, 0)`
|
|
: isPositions.isBottomLeft
|
|
? `top: -4px; right: ${tooltipArrowOffsetX}px; transform: translate(0, 0) rotate(180deg)`
|
|
: isPositions.isBottomRight
|
|
? `top: -4px; left: ${tooltipArrowOffsetX}px; transform: translate(0, 0) rotate(180deg)`
|
|
: '',
|
|
]"
|
|
>
|
|
<svg
|
|
width="6"
|
|
height="4"
|
|
viewBox="0 0 6 4"
|
|
fill="none"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
<path
|
|
d="M2.2 2.93333L0 0H6L3.8 2.93333C3.4 3.46667 2.6 3.46667 2.2 2.93333Z"
|
|
:fill="backgroundColor"
|
|
/>
|
|
</svg>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<ClientOnly>
|
|
<Teleport to="body">
|
|
<div
|
|
v-if="teleport && isOpen"
|
|
:class="[
|
|
'absolute z-[100] flex items-center justify-center w-[280px] py-[8px] px-[12px] rounded-[4px] text-center text-[12px] font-[400] leading-[18px] tracking-[-0.24px] sm:w-auto sm:max-w-[280px]',
|
|
]"
|
|
:style="[
|
|
isPositions.isTop
|
|
? `transform: translate(-50%, -100%)`
|
|
: isPositions.isBottom
|
|
? `transform: translate(-50%, 100%)`
|
|
: isPositions.isLeft
|
|
? `transform: translate(0, -50%)`
|
|
: isPositions.isRight
|
|
? `transform: translate(0, -50%)`
|
|
: isPositions.isTopLeft
|
|
? `transform: translate(-100%, -100%)`
|
|
: isPositions.isTopRight
|
|
? `transform: translate(0, -100%)`
|
|
: isPositions.isBottomLeft
|
|
? `transform: translate(-100%, 100%)`
|
|
: isPositions.isBottomRight
|
|
? `transform: translate(0, 100%)`
|
|
: '',
|
|
`top: ${tooltipPosition.top !== null ? `${tooltipPosition.top}px` : 'auto'}; bottom: ${tooltipPosition.bottom !== null ? `${tooltipPosition.bottom}px` : 'auto'}; left: ${tooltipPosition.left !== null ? `${tooltipPosition.left}px` : 'auto'}; right: ${tooltipPosition.right !== null ? `${tooltipPosition.right}px` : 'auto'}; background-color: ${backgroundColor};`,
|
|
]"
|
|
>
|
|
<slot name="panel" />
|
|
|
|
<p
|
|
v-if="!$slots.panel"
|
|
v-dompurify-html="props.content"
|
|
:class="[
|
|
`relative flex items-center justify-center w-full text-${props.textAlign} text-[${props.fontSize}] font-[${props.fontWeight}] leading-[${props.lineHeight}] tracking-[${props.letterSpacing}]`,
|
|
]"
|
|
:style="[`color: ${props.textColor};`]"
|
|
></p>
|
|
|
|
<span
|
|
v-if="arrow"
|
|
class="absolute"
|
|
:style="[
|
|
isPositions.isTop
|
|
? `bottom: -4px; left: 50%; transform: translate(-50%, 0)`
|
|
: isPositions.isBottom
|
|
? `top: -4px; left: 50%; transform: translate(-50%, 0) rotate(180deg)`
|
|
: isPositions.isLeft
|
|
? `right: -4px; top: 50%; transform: translate(0, -50%) rotate(-90deg)`
|
|
: isPositions.isRight
|
|
? `left: -4px; top: 50%; transform: translate(0, -50%) rotate(90deg)`
|
|
: isPositions.isTopLeft
|
|
? `bottom: -4px; right: ${tooltipArrowOffsetX}px; transform: translate(0, 0)`
|
|
: isPositions.isTopRight
|
|
? `bottom: -4px; left: ${tooltipArrowOffsetX}px; transform: translate(0, 0)`
|
|
: isPositions.isBottomLeft
|
|
? `top: -4px; right: ${tooltipArrowOffsetX}px; transform: translate(0, 0) rotate(180deg)`
|
|
: isPositions.isBottomRight
|
|
? `top: -4px; left: ${tooltipArrowOffsetX}px; transform: translate(0, 0) rotate(180deg)`
|
|
: '',
|
|
]"
|
|
>
|
|
<svg
|
|
width="6"
|
|
height="4"
|
|
viewBox="0 0 6 4"
|
|
fill="none"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
<path
|
|
d="M2.2 2.93333L0 0H6L3.8 2.93333C3.4 3.46667 2.6 3.46667 2.2 2.93333Z"
|
|
:fill="backgroundColor"
|
|
/>
|
|
</svg>
|
|
</span>
|
|
</div>
|
|
</Teleport>
|
|
</ClientOnly>
|
|
</template>
|
|
|
|
<style scoped></style>
|