feat: 다운로드페이지-플랫폼 및 OS타입별 추가

This commit is contained in:
최만억 (Jo)
2025-11-24 07:29:11 +00:00
committed by 김채린
parent 2a2631d3bc
commit cc5db808b2
4 changed files with 589 additions and 84 deletions

View 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>