diff --git a/src/ellipsisText/__tests__/useTextStyle.test.tsx b/src/ellipsisText/__tests__/useTextStyle.test.tsx
new file mode 100644
index 000000000..b5038846d
--- /dev/null
+++ b/src/ellipsisText/__tests__/useTextStyle.test.tsx
@@ -0,0 +1,168 @@
+import React from 'react';
+import { cleanup, render } from '@testing-library/react';
+import { act, renderHook } from '@testing-library/react-hooks';
+
+import useTextStyle from '../useTextStyle';
+import {
+ getAvailableWidth,
+ getRangeWidth,
+ getStyle,
+ getValidContainerElement,
+ transitionWidth,
+} from '../utils';
+
+jest.mock('../utils', () => ({
+ getValidContainerElement: jest.fn(),
+ getRangeWidth: jest.fn(),
+ getStyle: jest.fn(),
+ getAvailableWidth: jest.fn(),
+ transitionWidth: jest.fn(),
+}));
+
+describe('Test useTextStyle', () => {
+ const mockGetRangeWidth = getRangeWidth as jest.Mock;
+ const mockGetStyle = getStyle as jest.Mock;
+ const mockGetValidContainerElement = getValidContainerElement as jest.Mock;
+ const mockTransitionWidth = transitionWidth as jest.Mock;
+ const mockGetAvailableWidth = getAvailableWidth as jest.Mock;
+
+ beforeEach(() => {
+ cleanup();
+ jest.clearAllMocks();
+ mockGetValidContainerElement.mockReturnValue(
);
+ });
+
+ it('should return a ref, overflow state, style, and trigger function', () => {
+ const { result } = renderHook(() => useTextStyle('Test Text'));
+ const [ref, isOverflow, style, updateTextStyle] = result.current;
+
+ expect(ref).toBeInstanceOf(Object);
+ expect(typeof isOverflow).toBe('boolean');
+ expect(style).toBeInstanceOf(Object);
+ expect(typeof updateTextStyle).toBe('function');
+ });
+
+ it('should calculate overflow correctly', () => {
+ const { result } = renderHook(() => useTextStyle('Test Text'));
+ const [ref, , , updateTextStyle] = result.current;
+
+ render();
+
+ mockGetRangeWidth.mockReturnValue(150);
+ mockGetAvailableWidth.mockReturnValue(100);
+
+ act(() => {
+ updateTextStyle();
+ });
+
+ const [, isOverflow, style] = result.current;
+
+ expect(mockGetRangeWidth).toHaveBeenCalled();
+ expect(isOverflow).toBe(true);
+ expect(style.maxWidth).toBe(100);
+ });
+
+ it('should not overflow if the container width is sufficient', () => {
+ const { result } = renderHook(() => useTextStyle('Test Text'));
+ const [ref, , , updateTextStyle] = result.current;
+
+ render();
+
+ mockGetRangeWidth.mockReturnValue(80);
+ mockGetAvailableWidth.mockReturnValue(100);
+
+ act(() => {
+ updateTextStyle();
+ });
+ const [, isOverflow, style] = result.current;
+
+ expect(isOverflow).toBe(false);
+ expect(style.maxWidth).toBe(100);
+ });
+
+ it('should inherit cursor style from parent', () => {
+ const { result } = renderHook(() => useTextStyle('Test Text'));
+ const [ref, , , updateTextStyle] = result.current;
+
+ render();
+
+ mockGetStyle.mockReturnValue('pointer');
+
+ act(() => {
+ updateTextStyle();
+ });
+ const [, , style] = result.current;
+
+ expect(style.cursor).toBe('pointer');
+ });
+
+ it('should have cursor style as default when text is not overflowing and parent cursor is default', () => {
+ const { result } = renderHook(() => useTextStyle('Test Text'));
+ const [ref, , , updateTextStyle] = result.current;
+
+ render();
+
+ mockGetRangeWidth.mockReturnValue(80);
+ mockGetAvailableWidth.mockReturnValue(100);
+ mockGetStyle.mockReturnValue('default');
+
+ act(() => {
+ updateTextStyle();
+ });
+ const [, isOverflow, style] = result.current;
+
+ expect(isOverflow).toBe(false);
+ expect(style.cursor).toBe('default');
+ });
+
+ it('should have cursor style as pointer when text is overflowing and parent cursor is default', () => {
+ const { result } = renderHook(() => useTextStyle('Test Text'));
+ const [ref, , , updateTextStyle] = result.current;
+
+ render();
+
+ mockGetRangeWidth.mockReturnValue(150);
+ mockGetAvailableWidth.mockReturnValue(100);
+ mockGetStyle.mockReturnValue('default');
+
+ act(() => {
+ updateTextStyle();
+ });
+ const [, isOverflow, style] = result.current;
+
+ expect(isOverflow).toBe(true);
+ expect(style.cursor).toBe('pointer');
+ });
+
+ it('should set container width when container width < maxWidth', () => {
+ const { result } = renderHook(() => useTextStyle('Test Text', 120));
+ const [ref, , , updateTextStyle] = result.current;
+
+ render();
+
+ mockTransitionWidth.mockReturnValue(100);
+
+ act(() => {
+ updateTextStyle();
+ });
+ const [, , style] = result.current;
+
+ expect(style.maxWidth).toBe(100);
+ });
+
+ it('should set maxWidth when container width > maxWidth ', () => {
+ const { result } = renderHook(() => useTextStyle('Test Text', 80));
+ const [ref, , , updateTextStyle] = result.current;
+
+ render();
+
+ mockTransitionWidth.mockReturnValue(80);
+
+ act(() => {
+ updateTextStyle();
+ });
+ const [, , style] = result.current;
+
+ expect(style.maxWidth).toBe(80);
+ });
+});
diff --git a/src/ellipsisText/index.md b/src/ellipsisText/index.md
index a658adfed..86d0aa1b0 100644
--- a/src/ellipsisText/index.md
+++ b/src/ellipsisText/index.md
@@ -28,13 +28,13 @@ demo:
## API
-| 参数 | 说明 | 类型 | 默认值 |
-| --------------------- | ------------------------------------------ | -------------------------------- | ------ |
-| value | 显示文本内容 | `ReactNode \| () => ReactNode` | - |
-| title | 提示文字 | `ReactNode \| () => ReactNode` | value |
-| className | 为文本内容所在节点添加自定义样式名 | `string` | - |
-| maxWidth | 文本内容的最大宽度 | `string \| number` | - |
-| watchParentSizeChange | 监听父元素大小的变更,默认监听 window 窗口 | ` boolean` | false |
+| 参数 | 说明 | 类型 | 默认值 |
+| --------------------- | ---------------------------------------------------------------- | ------------------------------ | ------ |
+| value | 显示文本内容 | `ReactNode \| () => ReactNode` | - |
+| title | 提示文字 | `ReactNode \| () => ReactNode` | value |
+| className | 为文本内容所在节点添加自定义样式名 | `string` | - |
+| maxWidth | 文本内容的最大宽度,默认自动计算父元素中的剩余宽度作为文本的宽度 | `string \| number` | - |
+| watchParentSizeChange | 监听父元素大小的变更,默认监听 window 窗口 | ` boolean` | false |
:::info
其余参数继承自 [继承 antd4.x 的 Tooltip](https://4x.ant.design/components/tooltip-cn/#API)
diff --git a/src/ellipsisText/index.tsx b/src/ellipsisText/index.tsx
index 65662dfd4..6609ba5b3 100644
--- a/src/ellipsisText/index.tsx
+++ b/src/ellipsisText/index.tsx
@@ -1,23 +1,19 @@
-import React, {
- CSSProperties,
- ReactNode,
- useCallback,
- useLayoutEffect,
- useRef,
- useState,
-} from 'react';
+import React, { ReactNode, useCallback } from 'react';
import { Tooltip } from 'antd';
import { AbstractTooltipProps, RenderFunction } from 'antd/lib/tooltip';
import classNames from 'classnames';
import Resize from '../resize';
+import useTextStyle from './useTextStyle';
import './style.scss';
+export const DEFAULT_MAX_WIDTH = 120;
+
export interface IEllipsisTextProps extends AbstractTooltipProps {
/**
* 文本内容
*/
- value: string | number | ReactNode | RenderFunction;
+ value: ReactNode | RenderFunction;
/**
* 提示内容
* @default value
@@ -41,12 +37,6 @@ export interface IEllipsisTextProps extends AbstractTooltipProps {
[propName: string]: any;
}
-export interface NewHTMLElement extends HTMLElement {
- currentStyle?: CSSStyleDeclaration;
-}
-
-const DEFAULT_MAX_WIDTH = 120;
-
const EllipsisText = (props: IEllipsisTextProps) => {
const {
value,
@@ -56,190 +46,22 @@ const EllipsisText = (props: IEllipsisTextProps) => {
watchParentSizeChange = false,
...otherProps
} = props;
+ const [ref, isOverflow, style, onResize] = useTextStyle(value, maxWidth);
- const ellipsisRef = useRef(null);
const observerEle =
- watchParentSizeChange && ellipsisRef.current?.parentElement
- ? ellipsisRef.current?.parentElement
- : null;
-
- const [visible, setVisible] = useState(false);
- const [width, setWidth] = useState(DEFAULT_MAX_WIDTH);
- const [cursor, setCursor] = useState('default');
-
- useLayoutEffect(() => {
- onResize();
- }, [value, maxWidth]);
-
- /**
- * @description: 根据属性名,获取dom的属性值
- * @param {NewHTMLElement} dom
- * @param {string} attr
- * @return {*}
- */
- const getStyle = (dom: NewHTMLElement, attr: string) => {
- // Compatible width IE8
- // @ts-ignore
- return window.getComputedStyle(dom)[attr] || dom.currentStyle[attr];
- };
-
- /**
- * @description: 根据属性名,获取dom的属性值为number的属性。如: height、width。。。
- * @param {NewHTMLElement} dom
- * @param {string} attr
- * @return {*}
- */
- const getNumTypeStyleValue = (dom: NewHTMLElement, attr: string) => {
- return parseInt(getStyle(dom, attr));
- };
-
- /**
- * @description: 10 -> 10,
- * @description: 10px -> 10,
- * @description: 90% -> ele.width * 0.9
- * @description: calc(100% - 32px) -> ele.width - 32
- * @param {*} ele
- * @param {string & number} maxWidth
- * @return {*}
- */
- const transitionWidth = (ele: HTMLElement, maxWidth: string | number) => {
- const eleWidth = getActualWidth(ele);
-
- if (typeof maxWidth === 'number') {
- return maxWidth > eleWidth ? eleWidth : maxWidth; // 如果父元素的宽度小于传入的最大宽度,返回父元素的宽度
- }
-
- const numMatch = maxWidth.match(/^(\d+)(px)?$/);
- if (numMatch) {
- return +numMatch[1] > eleWidth ? eleWidth : +numMatch[1]; // 如果父元素的宽度小于传入的最大宽度,返回父元素的宽度
- }
-
- const percentMatch = maxWidth.match(/^(\d+)%$/);
- if (percentMatch) {
- return eleWidth * (parseInt(percentMatch[1]) / 100);
- }
-
- const relativeMatch = maxWidth.match(/^calc\(100% - (\d+)px\)$/);
- if (relativeMatch) {
- return eleWidth - parseInt(relativeMatch[1]);
- }
-
- return eleWidth;
- };
-
- const hideEleContent = (node: HTMLElement) => {
- node.style.display = 'none';
- };
-
- const showEleContent = (node: HTMLElement) => {
- node.style.display = 'inline-block';
- };
-
- /**
- * @description: 获取能够得到宽度的最近父元素宽度。行内元素无法获得宽度,需向上查找父元素
- * @param {HTMLElement} ele
- * @return {*}
- */
- const getContainerWidth = (ele: HTMLElement): number | string => {
- if (!ele) return DEFAULT_MAX_WIDTH;
-
- const { scrollWidth, parentElement } = ele;
-
- // 如果是行内元素,获取不到宽度,则向上寻找父元素
- if (scrollWidth === 0) {
- return getContainerWidth(parentElement!);
- }
- // 如果设置了最大宽度,则直接返回宽度
- if (maxWidth) {
- return transitionWidth(ele, maxWidth);
- }
-
- hideEleContent(ellipsisRef.current!);
-
- const availableWidth = getAvailableWidth(ele);
-
- return availableWidth < 0 ? 0 : availableWidth;
- };
-
- /**
- * @description: 获取dom元素的内容宽度
- * @param {HTMLElement} ele
- * @return {*}
- */
- const getRangeWidth = (ele: HTMLElement): any => {
- const range = document.createRange();
- range.selectNodeContents(ele);
- const rangeWidth = range.getBoundingClientRect().width;
-
- return rangeWidth;
- };
-
- /**
- * @description: 获取元素不包括 padding 的宽度
- * @param {HTMLElement} ele
- * @return {*}
- */
- const getActualWidth = (ele: HTMLElement) => {
- const width = ele.getBoundingClientRect().width;
- const paddingLeft = getNumTypeStyleValue(ele, 'paddingLeft');
- const paddingRight = getNumTypeStyleValue(ele, 'paddingRight');
- return width - paddingLeft - paddingRight;
- };
-
- /**
- * @description: 获取dom的可用宽度
- * @param {HTMLElement} ele
- * @return {*}
- */
- const getAvailableWidth = (ele: HTMLElement) => {
- const width = getActualWidth(ele);
- const contentWidth = getRangeWidth(ele);
- const ellipsisWidth = width - contentWidth;
- return ellipsisWidth;
- };
-
- /**
- * @description: 计算父元素的宽度是否满足内容的大小
- * @return {*}
- */
- const onResize = () => {
- const ellipsisNode = ellipsisRef.current!;
- const parentElement = ellipsisNode.parentElement!;
- const rangeWidth = getRangeWidth(ellipsisNode);
- const containerWidth = getContainerWidth(parentElement);
- const visible = rangeWidth > containerWidth;
- setVisible(visible);
- setWidth(containerWidth);
- const parentCursor = getStyle(parentElement, 'cursor');
- if (parentCursor !== 'default') {
- // 继承父元素的 hover 手势
- setCursor(parentCursor);
- } else {
- // 截取文本时,则改变 hover 手势为 pointer
- visible && setCursor('pointer');
- }
- showEleContent(ellipsisNode);
- };
+ watchParentSizeChange && ref.current?.parentElement ? ref.current?.parentElement : null;
const renderText = useCallback(() => {
- const style: CSSProperties = {
- maxWidth: width,
- cursor,
- };
return (
-
+
{typeof value === 'function' ? value() : value}
);
- }, [width, cursor, value]);
+ }, [style, value]);
return (
- {visible ? (
+ {isOverflow ? (
{renderText()}
diff --git a/src/ellipsisText/useTextStyle.ts b/src/ellipsisText/useTextStyle.ts
new file mode 100644
index 000000000..9b70b3073
--- /dev/null
+++ b/src/ellipsisText/useTextStyle.ts
@@ -0,0 +1,87 @@
+import { CSSProperties, RefObject, useLayoutEffect, useReducer, useRef } from 'react';
+
+import {
+ getAvailableWidth,
+ getRangeWidth,
+ getStyle,
+ getValidContainerElement,
+ transitionWidth,
+} from './utils';
+import { DEFAULT_MAX_WIDTH } from '.';
+
+const updateReducer = (num: number): number => (num + 1) % 1_000_000;
+export default function useTextStyle(
+ value: T,
+ maxWidth?: string | number
+): [RefObject, boolean, CSSProperties, () => void] {
+ const [, update] = useReducer(updateReducer, 0);
+
+ const style = useRef({ maxWidth: DEFAULT_MAX_WIDTH, cursor: 'default' });
+ const isOverflow = useRef(false);
+
+ const ref = useRef(null);
+
+ /**
+ * 获取能够得到宽度的最近父元素宽度。行内元素无法获得宽度,需向上查找父元素
+ * @returns {number}
+ */
+ const getTextContainerWidth = () => {
+ const textNode = ref.current!;
+ const parentElement = textNode.parentElement!;
+ const container = getValidContainerElement(parentElement);
+
+ if (!container) return DEFAULT_MAX_WIDTH;
+
+ let containerWidth;
+
+ if (maxWidth) {
+ containerWidth = transitionWidth(container, maxWidth);
+ } else {
+ // 这里是获取 ref 元素占的宽度,在计算时,需要把 ref 元素隐藏,以免计算时影响结果
+ const oldDisplay = textNode.style.display;
+ textNode.style.display = 'none';
+ const availableWidth = getAvailableWidth(container);
+ containerWidth = availableWidth < 0 ? 0 : availableWidth;
+ textNode.style.display = oldDisplay;
+ }
+
+ return containerWidth;
+ };
+
+ useLayoutEffect(() => {
+ updateTextStyle();
+ }, [value, maxWidth]);
+
+ const handleCursor = () => {
+ const textNode = ref.current!;
+ const parentElement = textNode.parentElement!;
+ const parentCursor = getStyle(parentElement, 'cursor');
+
+ if (parentCursor !== 'default') {
+ // 继承父元素的 hover 手势
+ style.current = { ...style.current, cursor: parentCursor };
+ } else {
+ // 截取文本时,则改变 hover 手势为 pointer
+ if (isOverflow.current) style.current = { ...style.current, cursor: 'pointer' };
+ }
+ };
+
+ /**
+ * @description: 计算父元素的宽度是否满足内容的大小
+ * @return {*}
+ */
+ const updateTextStyle = () => {
+ const textNode = ref.current;
+ if (!textNode) return;
+
+ const rangeWidth = getRangeWidth(textNode);
+ const containerWidth = getTextContainerWidth();
+
+ style.current = { ...style.current, maxWidth: containerWidth };
+ isOverflow.current = rangeWidth > containerWidth;
+ handleCursor();
+ update();
+ };
+
+ return [ref, isOverflow.current, style.current, updateTextStyle];
+}
diff --git a/src/ellipsisText/utils.ts b/src/ellipsisText/utils.ts
new file mode 100644
index 000000000..de63521a3
--- /dev/null
+++ b/src/ellipsisText/utils.ts
@@ -0,0 +1,117 @@
+export interface NewHTMLElement extends HTMLElement {
+ currentStyle?: CSSStyleDeclaration;
+}
+type Nullable = T | undefined | null;
+
+/**
+ * @description: 根据属性名,获取 dom 的属性值
+ * @param {NewHTMLElement} dom
+ * @param {string} attr
+ * @return {*}
+ */
+export const getStyle = (dom: NewHTMLElement, attr: string) => {
+ // Compatible width IE8
+ // @ts-ignore
+ return window.getComputedStyle(dom)[attr] || dom.currentStyle[attr];
+};
+
+/**
+ * @description: 根据属性名,获取dom的属性值为number的属性。如: height、width。。。
+ * @param {NewHTMLElement} dom
+ * @param {string} attr
+ * @return {*}
+ */
+export const getNumTypeStyleValue = (dom: NewHTMLElement, attr: string) => {
+ return parseInt(getStyle(dom, attr));
+};
+
+/**
+ * @description: 10 -> 10,
+ * @description: 10px -> 10,
+ * @description: 90% -> ele.width * 0.9
+ * @description: calc(100% - 32px) -> ele.width - 32
+ * @param {*} ele
+ * @param {string & number} maxWidth
+ * @return {*}
+ */
+export const transitionWidth = (ele: HTMLElement, maxWidth: string | number) => {
+ const eleWidth = getActualWidth(ele);
+
+ if (typeof maxWidth === 'number') {
+ return maxWidth > eleWidth ? eleWidth : maxWidth; // 如果父元素的宽度小于传入的最大宽度,返回父元素的宽度
+ }
+
+ const numMatch = maxWidth.match(/^(\d+)(px)?$/);
+ if (numMatch) {
+ return +numMatch[1] > eleWidth ? eleWidth : +numMatch[1]; // 如果父元素的宽度小于传入的最大宽度,返回父元素的宽度
+ }
+
+ const percentMatch = maxWidth.match(/^(\d+)%$/);
+ if (percentMatch) {
+ return eleWidth * (parseInt(percentMatch[1]) / 100);
+ }
+
+ const relativeMatch = maxWidth.match(/^calc\(100% - (\d+)px\)$/);
+ if (relativeMatch) {
+ return eleWidth - parseInt(relativeMatch[1]);
+ }
+
+ return eleWidth;
+};
+
+/**
+ * @description: 获取 dom 元素的内容宽度
+ * @param {HTMLElement} ele
+ * @return {*}
+ */
+export const getRangeWidth = (ele: HTMLElement): any => {
+ const range = document.createRange();
+ range.selectNodeContents(ele);
+ const rangeWidth = range.getBoundingClientRect().width;
+
+ return rangeWidth;
+};
+
+/**
+ * @description: 获取元素不包括 padding 的宽度
+ * @param {HTMLElement} ele
+ * @return {*}
+ */
+export const getActualWidth = (ele: HTMLElement) => {
+ const width = ele.getBoundingClientRect().width;
+ const paddingLeft = getNumTypeStyleValue(ele, 'paddingLeft');
+ const paddingRight = getNumTypeStyleValue(ele, 'paddingRight');
+
+ return width - paddingLeft - paddingRight;
+};
+
+/**
+ * @description: 获取 dom 的可用宽度
+ * @param {HTMLElement} ele
+ * @return {*}
+ */
+export const getAvailableWidth = (ele: HTMLElement) => {
+ const width = getActualWidth(ele);
+ const contentWidth = getRangeWidth(ele);
+ const ellipsisWidth = width - contentWidth;
+
+ return ellipsisWidth;
+};
+
+/**
+ * @description 获取/向上获取有效的父元素(非行内元素)
+ * @param {Nullable} ele
+ * @returns {Nullable}
+ */
+export const getValidContainerElement = (ele: Nullable): Nullable => {
+ if (!ele) return ele;
+
+ const { scrollWidth, parentElement } = ele;
+
+ // 如果是行内元素,获取不到宽度,则向上寻找父元素
+ if (scrollWidth === 0) {
+ return getValidContainerElement(parentElement!);
+ }
+
+ return ele;
+};