mirror of
https://github.com/element-plus/element-plus.git
synced 2026-03-13 07:51:17 +08:00
feat: init ellipsis
This commit is contained in:
@@ -55,6 +55,10 @@
|
||||
{
|
||||
"link": "/typography",
|
||||
"text": "Typography"
|
||||
},
|
||||
{
|
||||
"link": "/ellipsis",
|
||||
"text": "Ellipsis"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
62
docs/en-US/component/ellipsis.md
Normal file
62
docs/en-US/component/ellipsis.md
Normal file
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: Ellipsis
|
||||
lang: en-US
|
||||
---
|
||||
|
||||
# Ellipsis
|
||||
|
||||
This can be used when there is a piece of text that is too long to fit.
|
||||
|
||||
## Basic usage
|
||||
|
||||
:::demo
|
||||
|
||||
ellipsis/basic-usage
|
||||
|
||||
:::
|
||||
|
||||
## Expand
|
||||
|
||||
:::demo
|
||||
|
||||
empty/custom-image
|
||||
|
||||
:::
|
||||
|
||||
## Image size
|
||||
|
||||
Use `image-size` prop to control image size.
|
||||
|
||||
:::demo
|
||||
|
||||
empty/image-size
|
||||
|
||||
:::
|
||||
|
||||
## Bottom content
|
||||
|
||||
Use the default slot to insert content at the bottom.
|
||||
|
||||
:::demo
|
||||
|
||||
empty/bottom-content
|
||||
|
||||
:::
|
||||
|
||||
## API
|
||||
|
||||
### Attributes
|
||||
|
||||
| Name | Description | Type | Default |
|
||||
| ----------- | --------------------------- | --------- | ------- |
|
||||
| image | image URL of empty | ^[string] | '' |
|
||||
| image-size | image size (width) of empty | ^[number] | — |
|
||||
| description | description of empty | ^[string] | '' |
|
||||
|
||||
### Slots
|
||||
|
||||
| Name | Description |
|
||||
| ----------- | ------------------------- |
|
||||
| default | content as bottom content |
|
||||
| image | content as image |
|
||||
| description | content as description |
|
||||
13
docs/examples/ellipsis/basic-usage.vue
Normal file
13
docs/examples/ellipsis/basic-usage.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div style="max-width: 150px">
|
||||
<el-ellipsis tooltip>
|
||||
The -webkit-line-clamp CSS property allows limiting of the contents of a
|
||||
block to the specified number of lines.
|
||||
</el-ellipsis>
|
||||
<br />
|
||||
<el-ellipsis :line-clamp="3" tooltip>
|
||||
The -webkit-line-clamp CSS property allows limiting of the contents of a
|
||||
block to the specified number of lines.
|
||||
</el-ellipsis>
|
||||
</div>
|
||||
</template>
|
||||
13
packages/components/ellipsis/__tests__/ellipsis.test.tsx
Normal file
13
packages/components/ellipsis/__tests__/ellipsis.test.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import Ellipsis from '../src/ellipsis.vue'
|
||||
|
||||
const AXIOM = 'Rem is the best girl'
|
||||
|
||||
describe('Ellipsis.vue', () => {
|
||||
test('render test', () => {
|
||||
const wrapper = mount(() => <Ellipsis>{AXIOM}</Ellipsis>)
|
||||
|
||||
expect(wrapper.text()).toEqual(AXIOM)
|
||||
})
|
||||
})
|
||||
7
packages/components/ellipsis/index.ts
Normal file
7
packages/components/ellipsis/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { withInstall } from '@element-plus/utils'
|
||||
import Ellipsis from './src/ellipsis'
|
||||
|
||||
export const ElEllipsis = withInstall(Ellipsis)
|
||||
export default ElEllipsis
|
||||
|
||||
export * from './src/ellipsis'
|
||||
365
packages/components/ellipsis/src/ellipsis.tsx
Normal file
365
packages/components/ellipsis/src/ellipsis.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
import { computed, defineComponent, nextTick, ref, watch } from 'vue'
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
import { buildProps, definePropType } from '@element-plus/utils'
|
||||
import { useNamespace } from '@element-plus/hooks'
|
||||
import ElTooltip from '@element-plus/components/tooltip'
|
||||
import ELMeasure from './measure.vue'
|
||||
|
||||
import type { ElTooltipProps } from '@element-plus/components/tooltip'
|
||||
import type { CSSProperties, ExtractPropTypes, VNode } from 'vue'
|
||||
|
||||
type ELMeasureInstance = InstanceType<typeof ELMeasure>
|
||||
|
||||
const ELLIPSIS_SYMBOL = '...'
|
||||
|
||||
export const ellipsisProps = buildProps({
|
||||
lineClamp: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
expanded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hiddenSymbol: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
tooltip: {
|
||||
type: definePropType<boolean | Partial<ElTooltipProps>>([Boolean, Object]),
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const cuttable = (node: VNode) => {
|
||||
// ShapeFlags.TEXT_CHILDREN
|
||||
return node.shapeFlag === 1 << 3
|
||||
}
|
||||
|
||||
const getNodesLen = (nodes: VNode[]) => {
|
||||
let totalLen = 0
|
||||
|
||||
nodes.forEach((node) => {
|
||||
if (cuttable(node)) {
|
||||
const len = (node.children?.length as number) || 0
|
||||
totalLen += len
|
||||
} else {
|
||||
totalLen += 1
|
||||
}
|
||||
})
|
||||
|
||||
return totalLen
|
||||
}
|
||||
|
||||
function sliceNodes(nodeList: VNode[], len: number) {
|
||||
let currLen = 0
|
||||
const currentNodeList: (VNode | string)[] = []
|
||||
|
||||
for (const node of nodeList) {
|
||||
if (currLen === len) {
|
||||
return currentNodeList
|
||||
}
|
||||
|
||||
const canCut = cuttable(node)
|
||||
const nodeLen = canCut ? (node.children?.length as number) || 0 : 1
|
||||
const nextLen = currLen + nodeLen
|
||||
|
||||
// Exceed but current not which means we need cut this
|
||||
// This will not happen on validate Element
|
||||
if (nextLen > len) {
|
||||
const restLen = len - currLen
|
||||
currentNodeList.push(((node.children as string) || '').slice(0, restLen))
|
||||
return currentNodeList
|
||||
}
|
||||
|
||||
currentNodeList.push(node)
|
||||
currLen = nextLen
|
||||
}
|
||||
|
||||
return nodeList
|
||||
}
|
||||
|
||||
function getAriaLabel(nodes: VNode[]) {
|
||||
let text = ''
|
||||
nodes.forEach((node) => {
|
||||
if (cuttable(node)) {
|
||||
text += node.children || ''
|
||||
}
|
||||
})
|
||||
return text
|
||||
}
|
||||
|
||||
// Measure for the `text` is exceed the `lineClamp` or not
|
||||
const lineClipStyle: CSSProperties = {
|
||||
display: '-webkit-box',
|
||||
overflow: 'hidden',
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}
|
||||
|
||||
enum MeasureStatus {
|
||||
NONE,
|
||||
START,
|
||||
MEASURING,
|
||||
NO_NEED_ELLIPSIS,
|
||||
MEASURED,
|
||||
}
|
||||
|
||||
const Ellipsis = defineComponent({
|
||||
name: 'ElEllipsis',
|
||||
props: ellipsisProps,
|
||||
setup(props, { slots }) {
|
||||
const measureStatus = ref(MeasureStatus.NONE)
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const baseRowsEllipsisRef = ref<ELMeasureInstance | null>(null)
|
||||
const descRowsEllipsisRef = ref<ELMeasureInstance | null>(null)
|
||||
const symbolRowEllipsisRef = ref<ELMeasureInstance | null>(null)
|
||||
const cutMidRef = ref<ELMeasureInstance | null>(null)
|
||||
|
||||
const width = ref(0)
|
||||
const cutMidIndex = ref(0)
|
||||
const finalCutIndex = ref(0)
|
||||
const needEllipsis = ref(false)
|
||||
|
||||
const enableMeasure = computed(() => props.hiddenSymbol || slots.suffix)
|
||||
|
||||
const measure = async () => {
|
||||
if (!enableMeasure.value) return
|
||||
measureStatus.value = MeasureStatus.START
|
||||
needEllipsis.value = false
|
||||
await nextTick()
|
||||
// Get the basic height of ellipsis lineClamp
|
||||
const baseRowsEllipsisHeight = baseRowsEllipsisRef.value?.getHeight() || 0
|
||||
// Get the height of `lineClamp - 1` + symbol height
|
||||
const descRowsEllipsisHeight =
|
||||
props.lineClamp === 1 ? 0 : descRowsEllipsisRef.value?.getHeight() || 0
|
||||
|
||||
const symbolRowEllipsisHeight =
|
||||
symbolRowEllipsisRef.value?.getHeight() || 0
|
||||
|
||||
const rowsWithEllipsisHeight =
|
||||
descRowsEllipsisHeight + symbolRowEllipsisHeight
|
||||
|
||||
const maxRowsHeight = Math.max(
|
||||
baseRowsEllipsisHeight,
|
||||
rowsWithEllipsisHeight
|
||||
)
|
||||
|
||||
needEllipsis.value = !!baseRowsEllipsisRef.value?.isExceed()
|
||||
measureStatus.value = needEllipsis.value
|
||||
? MeasureStatus.MEASURING
|
||||
: MeasureStatus.NO_NEED_ELLIPSIS
|
||||
|
||||
if (needEllipsis.value) {
|
||||
const content = slots.default?.() || []
|
||||
const nodesLen = getNodesLen(content)
|
||||
finalCutIndex.value = await getFinalCutIndex(
|
||||
[0, nodesLen],
|
||||
maxRowsHeight + 1
|
||||
)
|
||||
measureStatus.value = MeasureStatus.MEASURED
|
||||
}
|
||||
}
|
||||
|
||||
// Get cutting position using binary search
|
||||
const getFinalCutIndex = async (
|
||||
ellipsisCutIndex: [number, number],
|
||||
ellipsisHeight: number
|
||||
) => {
|
||||
const [minIndex, maxIndex] = ellipsisCutIndex
|
||||
if (minIndex === maxIndex) return minIndex
|
||||
cutMidIndex.value = Math.ceil((minIndex + maxIndex) / 2)
|
||||
await nextTick()
|
||||
const midHeight = cutMidRef.value?.getHeight() || 0
|
||||
|
||||
const isOverflow = midHeight > ellipsisHeight
|
||||
const targetMidIndex = cutMidIndex.value
|
||||
|
||||
if (maxIndex - minIndex === 1) {
|
||||
return isOverflow ? minIndex : maxIndex
|
||||
}
|
||||
|
||||
if (isOverflow) {
|
||||
return getFinalCutIndex([minIndex, targetMidIndex], ellipsisHeight)
|
||||
} else {
|
||||
return getFinalCutIndex([targetMidIndex, maxIndex], ellipsisHeight)
|
||||
}
|
||||
}
|
||||
|
||||
const ns = useNamespace('ellipsis')
|
||||
|
||||
const kls = computed(() => [
|
||||
ns.b(),
|
||||
ns.is('single-line', !enableMeasure.value && props.lineClamp === 1),
|
||||
ns.is('multiple-line', !enableMeasure.value && props.lineClamp > 1),
|
||||
])
|
||||
|
||||
const multipleStyle = computed(() =>
|
||||
!enableMeasure.value && props.lineClamp > 1
|
||||
? { WebkitLineClamp: props.lineClamp }
|
||||
: {}
|
||||
)
|
||||
|
||||
const enableTooltip = computed(() => {
|
||||
if (props.expanded) return false
|
||||
if (!containerRef.value) return false
|
||||
if (enableMeasure.value) return needEllipsis.value
|
||||
const textEle = containerRef.value
|
||||
if (props.lineClamp === 1)
|
||||
return textEle.offsetWidth < textEle.scrollWidth
|
||||
if (props.lineClamp > 1)
|
||||
return textEle.offsetHeight < textEle.scrollHeight
|
||||
return false
|
||||
})
|
||||
|
||||
useResizeObserver(
|
||||
computed(() => (enableMeasure.value ? containerRef.value : null)),
|
||||
(entries) => {
|
||||
width.value = entries[0].contentRect.width
|
||||
}
|
||||
)
|
||||
|
||||
watch([width, () => props.lineClamp, () => props.hiddenSymbol], measure)
|
||||
|
||||
// fix: https://github.com/vuejs/core/issues/3257
|
||||
let slotWatchStop: (() => void) | null = null
|
||||
const slotsWatchFn = () => {
|
||||
if (slotWatchStop) slotWatchStop()
|
||||
slotWatchStop = watch(
|
||||
[() => slots.default?.(), () => slots.suffix?.({ needEllipsis: true })],
|
||||
measure
|
||||
)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (!slots.default) return null
|
||||
const { lineClamp } = props
|
||||
const content = slots.default()
|
||||
const suffix = slots.suffix?.({ needEllipsis: needEllipsis.value })
|
||||
const ariaLabel = enableMeasure.value ? getAriaLabel(content) : undefined
|
||||
|
||||
slotsWatchFn()
|
||||
|
||||
const renderChildren = (
|
||||
nodes: (string | VNode)[],
|
||||
isEllipsis: boolean
|
||||
) => {
|
||||
if (!isEllipsis) return [nodes, suffix]
|
||||
return [
|
||||
nodes,
|
||||
<span aria-hidden="true">{ELLIPSIS_SYMBOL}</span>,
|
||||
suffix,
|
||||
]
|
||||
}
|
||||
|
||||
const renderFinalContent = () => {
|
||||
if (!enableMeasure.value) return content
|
||||
|
||||
if (measureStatus.value === MeasureStatus.NO_NEED_ELLIPSIS) {
|
||||
return renderChildren(content, false)
|
||||
}
|
||||
|
||||
if (measureStatus.value === MeasureStatus.MEASURED) {
|
||||
return renderChildren(
|
||||
props.expanded ? content : sliceNodes(content, finalCutIndex.value),
|
||||
needEllipsis.value && !props.expanded
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
...lineClipStyle,
|
||||
WebkitLineClamp: props.lineClamp,
|
||||
}}
|
||||
>
|
||||
{renderChildren(content, true)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const container = (
|
||||
<div
|
||||
ref={containerRef}
|
||||
class={kls.value}
|
||||
style={multipleStyle.value}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{renderFinalContent()}
|
||||
{measureStatus.value === MeasureStatus.START && (
|
||||
<>
|
||||
{/** With `lineClamp` */}
|
||||
<ELMeasure
|
||||
style={{
|
||||
width: `${width.value}px`,
|
||||
...lineClipStyle,
|
||||
WebkitLineClamp: lineClamp,
|
||||
}}
|
||||
ref={baseRowsEllipsisRef}
|
||||
>
|
||||
{renderChildren(content, false)}
|
||||
</ELMeasure>
|
||||
|
||||
{/** With `lineClamp - 1` */}
|
||||
<ELMeasure
|
||||
style={{
|
||||
width: `${width.value}px`,
|
||||
...lineClipStyle,
|
||||
WebkitLineClamp: lineClamp - 1,
|
||||
}}
|
||||
ref={descRowsEllipsisRef}
|
||||
>
|
||||
{renderChildren(content, false)}
|
||||
</ELMeasure>
|
||||
|
||||
{/** With `symbol row` */}
|
||||
<ELMeasure
|
||||
style={{
|
||||
width: `${width.value}px`,
|
||||
...lineClipStyle,
|
||||
WebkitLineClamp: 1,
|
||||
}}
|
||||
ref={symbolRowEllipsisRef}
|
||||
>
|
||||
{renderChildren([], true)}
|
||||
</ELMeasure>
|
||||
</>
|
||||
)}
|
||||
{measureStatus.value === MeasureStatus.MEASURING && (
|
||||
<ELMeasure
|
||||
style={{
|
||||
width: `${width.value}px`,
|
||||
top: 400,
|
||||
}}
|
||||
ref={cutMidRef}
|
||||
>
|
||||
{renderChildren(sliceNodes(content, cutMidIndex.value), true)}
|
||||
</ELMeasure>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (props.tooltip) {
|
||||
const tooltipProps =
|
||||
typeof props.tooltip === 'object' ? props.tooltip : {}
|
||||
|
||||
return (
|
||||
<ElTooltip
|
||||
placement="top"
|
||||
{...tooltipProps}
|
||||
disabled={!enableTooltip.value}
|
||||
>
|
||||
{{
|
||||
default: () => container,
|
||||
content: () => content,
|
||||
}}
|
||||
</ElTooltip>
|
||||
)
|
||||
} else return container
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export type EllipsisProps = ExtractPropTypes<typeof ellipsisProps>
|
||||
export type EllipsisInstance = InstanceType<typeof Ellipsis>
|
||||
|
||||
export default Ellipsis
|
||||
41
packages/components/ellipsis/src/measure.vue
Normal file
41
packages/components/ellipsis/src/measure.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<span ref="spanRef" aria-hidden :style="style">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { CSSProperties } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'ElMeasure',
|
||||
})
|
||||
|
||||
const spanRef = ref<HTMLSpanElement>()
|
||||
|
||||
const style: CSSProperties = {
|
||||
position: 'fixed',
|
||||
display: 'block',
|
||||
left: 0,
|
||||
top: 0,
|
||||
pointerEvents: 'none',
|
||||
backgroundColor: 'rgba(255, 0, 0, 0.65)',
|
||||
whiteSpace: 'normal',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
}
|
||||
|
||||
const isExceed = () => {
|
||||
const span = spanRef.value!
|
||||
return span.scrollHeight > span.clientHeight
|
||||
}
|
||||
|
||||
const getHeight = () => spanRef.value!.clientHeight
|
||||
|
||||
defineExpose({
|
||||
isExceed,
|
||||
getHeight,
|
||||
})
|
||||
</script>
|
||||
3
packages/components/ellipsis/style/css.ts
Normal file
3
packages/components/ellipsis/style/css.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import '@element-plus/components/base/style/css'
|
||||
import '@element-plus/components/tooltip/style/css'
|
||||
import '@element-plus/theme-chalk/el-ellipsis.css'
|
||||
3
packages/components/ellipsis/style/index.ts
Normal file
3
packages/components/ellipsis/style/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import '@element-plus/components/base/style'
|
||||
import '@element-plus/components/tooltip/style'
|
||||
import '@element-plus/theme-chalk/src/ellipsis.scss'
|
||||
@@ -73,6 +73,7 @@ export * from './watermark'
|
||||
export * from './tour'
|
||||
export * from './anchor'
|
||||
export * from './segmented'
|
||||
export * from './ellipsis'
|
||||
|
||||
// plugins
|
||||
export * from './infinite-scroll'
|
||||
|
||||
@@ -106,6 +106,7 @@ import { ElWatermark } from '@element-plus/components/watermark'
|
||||
import { ElTour, ElTourStep } from '@element-plus/components/tour'
|
||||
import { ElAnchor, ElAnchorLink } from '@element-plus/components/anchor'
|
||||
import { ElSegmented } from '@element-plus/components/segmented'
|
||||
import { ElEllipsis } from '@element-plus/components/ellipsis'
|
||||
|
||||
import type { Plugin } from 'vue'
|
||||
|
||||
@@ -214,4 +215,5 @@ export default [
|
||||
ElAnchor,
|
||||
ElAnchorLink,
|
||||
ElSegmented,
|
||||
ElEllipsis,
|
||||
] as Plugin[]
|
||||
|
||||
25
packages/theme-chalk/src/ellipsis.scss
Normal file
25
packages/theme-chalk/src/ellipsis.scss
Normal file
@@ -0,0 +1,25 @@
|
||||
@use 'sass:map';
|
||||
|
||||
@use 'mixins/mixins' as *;
|
||||
@use 'mixins/var' as *;
|
||||
@use 'common/var' as *;
|
||||
|
||||
@include b(ellipsis) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: getCssVar('font-size', 'base');
|
||||
color: getCssVar('text-color', 'regular');
|
||||
word-break: break-word;
|
||||
|
||||
@include when(single-line) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@include when(multiple-line) {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
}
|
||||
@@ -107,3 +107,4 @@
|
||||
@use './anchor.scss';
|
||||
@use './anchor-link.scss';
|
||||
@use './segmented.scss';
|
||||
@use './ellipsis.scss';
|
||||
|
||||
1
typings/components.d.ts
vendored
1
typings/components.d.ts
vendored
@@ -104,6 +104,7 @@ declare module '@vue/runtime-core' {
|
||||
ElAnchor: typeof import('../packages/element-plus')['ElAnchor']
|
||||
ElAnchorLink: typeof import('../packages/element-plus')['ElAnchorLink']
|
||||
ElSegmented: typeof import('../packages/element-plus')['ElSegmented']
|
||||
ElEllipsis: typeof import('../packages/element-plus')['ElEllipsis']
|
||||
}
|
||||
|
||||
interface ComponentCustomProperties {
|
||||
|
||||
Reference in New Issue
Block a user