diff --git a/docs/.vitepress/crowdin/en-US/pages/component.json b/docs/.vitepress/crowdin/en-US/pages/component.json index a9ad8276fe..1fd427c66a 100644 --- a/docs/.vitepress/crowdin/en-US/pages/component.json +++ b/docs/.vitepress/crowdin/en-US/pages/component.json @@ -55,6 +55,10 @@ { "link": "/typography", "text": "Typography" + }, + { + "link": "/ellipsis", + "text": "Ellipsis" } ] }, diff --git a/docs/en-US/component/ellipsis.md b/docs/en-US/component/ellipsis.md new file mode 100644 index 0000000000..5876defeaf --- /dev/null +++ b/docs/en-US/component/ellipsis.md @@ -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 | diff --git a/docs/examples/ellipsis/basic-usage.vue b/docs/examples/ellipsis/basic-usage.vue new file mode 100644 index 0000000000..9da949676e --- /dev/null +++ b/docs/examples/ellipsis/basic-usage.vue @@ -0,0 +1,13 @@ + diff --git a/packages/components/ellipsis/__tests__/ellipsis.test.tsx b/packages/components/ellipsis/__tests__/ellipsis.test.tsx new file mode 100644 index 0000000000..9f899b2e18 --- /dev/null +++ b/packages/components/ellipsis/__tests__/ellipsis.test.tsx @@ -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(() => {AXIOM}) + + expect(wrapper.text()).toEqual(AXIOM) + }) +}) diff --git a/packages/components/ellipsis/index.ts b/packages/components/ellipsis/index.ts new file mode 100644 index 0000000000..b07b99d32a --- /dev/null +++ b/packages/components/ellipsis/index.ts @@ -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' diff --git a/packages/components/ellipsis/src/ellipsis.tsx b/packages/components/ellipsis/src/ellipsis.tsx new file mode 100644 index 0000000000..2931df8321 --- /dev/null +++ b/packages/components/ellipsis/src/ellipsis.tsx @@ -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 + +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, 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(null) + const baseRowsEllipsisRef = ref(null) + const descRowsEllipsisRef = ref(null) + const symbolRowEllipsisRef = ref(null) + const cutMidRef = ref(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, + , + 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 ( + + {renderChildren(content, true)} + + ) + } + + const container = ( +
+ {renderFinalContent()} + {measureStatus.value === MeasureStatus.START && ( + <> + {/** With `lineClamp` */} + + {renderChildren(content, false)} + + + {/** With `lineClamp - 1` */} + + {renderChildren(content, false)} + + + {/** With `symbol row` */} + + {renderChildren([], true)} + + + )} + {measureStatus.value === MeasureStatus.MEASURING && ( + + {renderChildren(sliceNodes(content, cutMidIndex.value), true)} + + )} +
+ ) + + if (props.tooltip) { + const tooltipProps = + typeof props.tooltip === 'object' ? props.tooltip : {} + + return ( + + {{ + default: () => container, + content: () => content, + }} + + ) + } else return container + } + }, +}) + +export type EllipsisProps = ExtractPropTypes +export type EllipsisInstance = InstanceType + +export default Ellipsis diff --git a/packages/components/ellipsis/src/measure.vue b/packages/components/ellipsis/src/measure.vue new file mode 100644 index 0000000000..8b41249355 --- /dev/null +++ b/packages/components/ellipsis/src/measure.vue @@ -0,0 +1,41 @@ + + + diff --git a/packages/components/ellipsis/style/css.ts b/packages/components/ellipsis/style/css.ts new file mode 100644 index 0000000000..c8ed37c08c --- /dev/null +++ b/packages/components/ellipsis/style/css.ts @@ -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' diff --git a/packages/components/ellipsis/style/index.ts b/packages/components/ellipsis/style/index.ts new file mode 100644 index 0000000000..705458ba47 --- /dev/null +++ b/packages/components/ellipsis/style/index.ts @@ -0,0 +1,3 @@ +import '@element-plus/components/base/style' +import '@element-plus/components/tooltip/style' +import '@element-plus/theme-chalk/src/ellipsis.scss' diff --git a/packages/components/index.ts b/packages/components/index.ts index 8ce350fa3a..789ef870ae 100644 --- a/packages/components/index.ts +++ b/packages/components/index.ts @@ -73,6 +73,7 @@ export * from './watermark' export * from './tour' export * from './anchor' export * from './segmented' +export * from './ellipsis' // plugins export * from './infinite-scroll' diff --git a/packages/element-plus/component.ts b/packages/element-plus/component.ts index 9a27b864dc..822c707925 100644 --- a/packages/element-plus/component.ts +++ b/packages/element-plus/component.ts @@ -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[] diff --git a/packages/theme-chalk/src/ellipsis.scss b/packages/theme-chalk/src/ellipsis.scss new file mode 100644 index 0000000000..e0ad26fef8 --- /dev/null +++ b/packages/theme-chalk/src/ellipsis.scss @@ -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; + } +} \ No newline at end of file diff --git a/packages/theme-chalk/src/index.scss b/packages/theme-chalk/src/index.scss index 42bc269804..49af1f672c 100644 --- a/packages/theme-chalk/src/index.scss +++ b/packages/theme-chalk/src/index.scss @@ -107,3 +107,4 @@ @use './anchor.scss'; @use './anchor-link.scss'; @use './segmented.scss'; +@use './ellipsis.scss'; diff --git a/typings/components.d.ts b/typings/components.d.ts index d770af1c5d..09f5869aec 100644 --- a/typings/components.d.ts +++ b/typings/components.d.ts @@ -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 {