<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
const props = withDefaults(
defineProps<{
text?: string
rows?: number
tooltip?: boolean | string
tooltipProps?: Record<string, unknown>
reserve?: boolean
}>(),
{
text: undefined,
rows: 1,
tooltip: undefined,
tooltipProps: undefined,
reserve: false,
},
)
const textRef = ref<HTMLElement | null>(null)
const isActuallyTruncated = ref(false)
const innerTextContent = ref('')
const lineHeightPx = ref(0)
const reservedMinHeightPx = ref<number | null>(null)
const displayText = computed<string>(() => {
return (props.text ?? '').toString()
})
const tooltipEnabled = computed<boolean>(() => {
if (props.tooltip === false) return false
// undefined or true or string means allowed when truncated
return true
})
const tooltipContent = computed<string>(() => {
if (typeof props.tooltip === 'string') return props.tooltip
// Prefer the rendered slot/text innerText
const content = (innerTextContent.value || '').trim()
if (content) return content
// Fallback to prop text when innerText not yet measured
return displayText.value
})
let resizeObserver: ResizeObserver | null = null
function updateTruncationLater(): void {
// Measure after DOM updates/styles applied
nextTick(() => {
const el = textRef.value
if (!el) {
isActuallyTruncated.value = false
return
}
// Read live innerText if slot content used
innerTextContent.value = el.innerText ?? ''
const hasText = innerTextContent.value.trim().length > 0
if (!hasText) {
isActuallyTruncated.value = false
// still measure min-height if reserve is enabled
measureAndReserve(el)
return
}
const heightOverflow = el.scrollHeight > el.clientHeight + 1
const widthOverflow = el.scrollWidth > el.clientWidth + 1
isActuallyTruncated.value = heightOverflow || widthOverflow
measureAndReserve(el)
})
}
function measureAndReserve(el: HTMLElement): void {
if (!props.reserve || props.rows <= 1) {
reservedMinHeightPx.value = null
return
}
const cs = window.getComputedStyle(el)
const lh = cs.lineHeight
const fs = cs.fontSize
let px = 0
if (lh && lh !== 'normal') {
px = parseFloat(lh)
} else if (fs) {
px = parseFloat(fs) * 1.5
} else {
px = 20
}
lineHeightPx.value = px
reservedMinHeightPx.value = Math.max(0, Math.round(px * props.rows))
}
onMounted(() => {
updateTruncationLater()
if (window && 'ResizeObserver' in window) {
resizeObserver = new ResizeObserver(() => updateTruncationLater())
if (textRef.value) resizeObserver.observe(textRef.value)
}
})
onUnmounted(() => {
if (resizeObserver && textRef.value) {
resizeObserver.unobserve(textRef.value)
}
resizeObserver = null
window.removeEventListener('resize', updateTruncationLater)
})
watch(
() => [props.text, props.rows, props.tooltip, props.reserve],
() => updateTruncationLater(),
{ flush: 'post' },
)
const showTooltip = computed<boolean>(() => tooltipEnabled.value && isActuallyTruncated.value)
</script>
<template>
<el-tooltip
:disabled="!showTooltip"
:content="tooltipContent"
placement="top"
v-bind="tooltipProps"
>
<div
ref="textRef"
class="ellipsis-paragraph__content"
:class="{ 'is-single-line': rows === 1 }"
:style="{
'--line-clamp': String(rows),
minHeight: reserve && reservedMinHeightPx != null ? `${reservedMinHeightPx}px` : undefined,
}"
>
<slot>{{ text }}</slot>
</div>
</el-tooltip>
</template>
<style scoped>
.ellipsis-paragraph__content {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
/* multiline clamp */
line-clamp: var(--line-clamp);
-webkit-line-clamp: var(--line-clamp);
}
.ellipsis-paragraph__content.is-single-line {
display: block;
white-space: nowrap;
}
</style>