feat: 다운로드페이지-플랫폼 및 OS타입별 추가
This commit is contained in:
422
layers/components/atoms/Tooltip.vue
Normal file
422
layers/components/atoms/Tooltip.vue
Normal file
@@ -0,0 +1,422 @@
|
||||
<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-[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 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>
|
||||
</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>
|
||||
Reference in New Issue
Block a user