Skip to content

Commit c5a00da

Browse files
committed
feat: text overflow
1 parent 232d14b commit c5a00da

File tree

10 files changed

+769
-1
lines changed

10 files changed

+769
-1
lines changed

src/components/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,3 +155,5 @@ export { default as SliderCaptcha } from './stateless/SliderCaptcha'
155155
export { default as MoveChecker } from './stateless/MoveChecker'
156156
export { default as SafeHtml } from './stateless/SafeHtml'
157157
export { default as AnimatedIcon } from './stateless/AnimatedIcon'
158+
export { default as OverflowText } from './stateless/OverflowText'
159+
export { default as PortalTooltip } from './stateless/PortalTooltip'
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { Meta, Canvas, Story, ArgTypes } from '@storybook/addon-docs/blocks';
2+
import * as Stories from './OverflowText.stories';
3+
4+
<Meta of={Stories} />
5+
6+
# OverflowText (溢出文本)
7+
8+
一个智能文本组件,仅当文本实际溢出时才自动显示 Tooltip。
9+
支持单行和多行截断,并使用 `ResizeObserver` 进行稳健的溢出检测。
10+
11+
内部使用 `PortalTooltip` 实现。
12+
13+
## 参数属性 (Props)
14+
15+
<ArgTypes />
16+
17+
## 示例
18+
19+
### 单行截断
20+
默认行为。当文本超过容器宽度(由 `maxWidth` 控制)时,使用省略号截断。
21+
22+
<Canvas>
23+
<Story name="Single Line truncation" />
24+
</Canvas>
25+
26+
### 多行截断
27+
使用 `lines` 属性指定在截断前显示多少行。
28+
29+
<Canvas>
30+
<Story name="Multi-line (2 lines)" />
31+
</Canvas>
32+
33+
### 自定义 Tooltip 宽度
34+
你可以通过 `tooltipProps` 将属性传递给底层的 `PortalTooltip`。这对于通过 `overlayStyle` 自定义 Tooltip 悬浮层宽度非常有用。
35+
36+
```jsx
37+
<OverflowText
38+
text="..."
39+
maxWidth={200}
40+
tooltipProps={{
41+
overlayStyle: { maxWidth: 500 }
42+
}}
43+
/>
44+
```
45+
46+
<Canvas>
47+
<Story name="Custom Tooltip Width" />
48+
</Canvas>
49+
50+
### 始终显示
51+
强制显示 Tooltip,即本文本并未溢出(通常用于调试或特定的 UX 需求)。
52+
53+
<Canvas>
54+
<Story name="Always Show Tooltip" />
55+
</Canvas>
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import React from 'react'
2+
import OverflowText from './index'
3+
4+
export default {
5+
title: 'Stateless/OverflowText',
6+
component: OverflowText,
7+
argTypes: {
8+
text: { control: 'text', description: 'The text content to display.' },
9+
maxWidth: { control: 'text', description: 'CSS max-width for the container (e.g. "200px" or 200).' },
10+
lines: { control: 'number', description: 'Number of lines to show before truncating (0 for single line).' },
11+
alwaysShow: { control: 'boolean', description: 'Force tooltip to always show regardless of overflow.' },
12+
minWidth: { control: 'text', description: 'Minimum width.' },
13+
},
14+
}
15+
16+
const Template = (args) => (
17+
<div
18+
style={{
19+
display: 'flex',
20+
alignItems: 'center',
21+
justifyContent: 'center',
22+
minHeight: '150px',
23+
padding: '1px 20px',
24+
}}
25+
>
26+
<div style={{ border: '1px dashed #ccc', padding: '10px', width: 'fit-content' }}>
27+
<OverflowText {...args} />
28+
</div>
29+
</div>
30+
)
31+
32+
export const Default = Template.bind({})
33+
Default.args = {
34+
text: 'This is a long text that should be truncated when it exceeds the max width.',
35+
maxWidth: 200,
36+
}
37+
Default.storyName = 'Single Line truncation'
38+
39+
export const MultiLine = Template.bind({})
40+
MultiLine.args = {
41+
text: 'This is a long text that should span multiple lines but eventually get truncated if it exceeds the specified number of lines. It handles wrapping correctly and shows a tooltip with the full content.',
42+
maxWidth: 200,
43+
lines: 2,
44+
}
45+
MultiLine.storyName = 'Multi-line (2 lines)'
46+
47+
export const CustomTooltipWidth = Template.bind({})
48+
CustomTooltipWidth.args = {
49+
text: 'This is a very long text example. By default, the tooltip has a max-width of 300px. In this example, we override it to 500px to show more text on one line within the tooltip.',
50+
maxWidth: 200,
51+
tooltipProps: {
52+
overlayStyle: { maxWidth: 500 },
53+
},
54+
}
55+
CustomTooltipWidth.storyName = 'Custom Tooltip Width'
56+
57+
export const NoOverflow = Template.bind({})
58+
NoOverflow.args = {
59+
text: 'Short text',
60+
maxWidth: 200,
61+
}
62+
NoOverflow.storyName = 'No Overflow (No Tooltip)'
63+
64+
export const AlwaysShow = Template.bind({})
65+
AlwaysShow.args = {
66+
text: 'Short text',
67+
maxWidth: 200,
68+
alwaysShow: true,
69+
}
70+
AlwaysShow.storyName = 'Always Show Tooltip'
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import React, { useEffect, useRef, useState, useLayoutEffect } from 'react'
2+
import PortalTooltip from '../PortalTooltip'
3+
import styles from './index.module.less'
4+
5+
// SSR-safe useLayoutEffect
6+
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect
7+
8+
/**
9+
* OverflowText
10+
*
11+
* Renders text that shows a tooltip when it overflows.
12+
*
13+
* Supports:
14+
* - Single line truncation (default)
15+
* - Multi-line truncation (via props.lines)
16+
* - Auto-detection of overflow via ResizeObserver
17+
* - alwaysShow prop to force display
18+
* - onOverflowChange callback
19+
*/
20+
const OverflowText = ({
21+
text = '',
22+
minWidth = 0,
23+
maxWidth,
24+
className = '',
25+
style = {},
26+
tooltipProps = {},
27+
alwaysShow = false,
28+
lines = 0,
29+
onOverflowChange,
30+
}) => {
31+
const elRef = useRef(null)
32+
const [isOverflow, setIsOverflow] = useState(false)
33+
34+
const check = () => {
35+
const el = elRef.current
36+
if (!el) return
37+
38+
const isMulti = lines && Number(lines) > 0
39+
40+
// For single line: use scrollWidth > clientWidth
41+
// For multi line: use scrollHeight > clientHeight
42+
let overflow = false
43+
44+
if (isMulti) {
45+
overflow = el.scrollHeight > el.clientHeight
46+
} else {
47+
// Single line check
48+
overflow = el.scrollWidth > el.clientWidth
49+
}
50+
51+
setIsOverflow((prev) => {
52+
if (prev !== overflow) {
53+
if (typeof onOverflowChange === 'function') onOverflowChange(overflow)
54+
return overflow
55+
}
56+
return prev
57+
})
58+
}
59+
60+
// Check on mount and updates
61+
useIsomorphicLayoutEffect(() => {
62+
check()
63+
}, [text, lines, maxWidth, style, className])
64+
65+
useEffect(() => {
66+
const el = elRef.current
67+
if (!el || typeof window === 'undefined') return
68+
69+
// Re-check when fonts load (crucial for custom fonts delay)
70+
if (document.fonts) {
71+
document.fonts.ready.then(check)
72+
}
73+
74+
// ResizeObserver to detect container/element size changes
75+
let ro
76+
if ('ResizeObserver' in window) {
77+
ro = new ResizeObserver(check)
78+
ro.observe(el)
79+
}
80+
81+
// Fallback global resize
82+
window.addEventListener('resize', check)
83+
84+
return () => {
85+
if (ro) ro.disconnect()
86+
window.removeEventListener('resize', check)
87+
}
88+
}, [text, lines])
89+
90+
// Construct styles
91+
const effectiveMaxWidth = maxWidth
92+
93+
const spanStyle = {
94+
// Apply minWidth if provided
95+
...(minWidth ? { minWidth: typeof minWidth === 'number' ? `${minWidth}px` : minWidth } : {}),
96+
// Apply maxWidth if provided (this is the CSS max-width)
97+
...(effectiveMaxWidth
98+
? { maxWidth: typeof effectiveMaxWidth === 'number' ? `${effectiveMaxWidth}px` : effectiveMaxWidth }
99+
: {}),
100+
// Multi-line specific styles
101+
...(lines && Number(lines) > 0
102+
? { WebkitLineClamp: lines, display: '-webkit-box', WebkitBoxOrient: 'vertical', whiteSpace: 'normal' }
103+
: {}),
104+
...style,
105+
}
106+
107+
const classes = `${styles.ellipsis} ${lines && Number(lines) > 0 ? styles.multiLine : ''} ${className}`.trim()
108+
109+
const child = (
110+
<span ref={elRef} className={classes} style={spanStyle}>
111+
{text}
112+
</span>
113+
)
114+
115+
return alwaysShow || isOverflow ? (
116+
<PortalTooltip title={typeof text === 'string' ? text : undefined} {...tooltipProps}>
117+
{child}
118+
</PortalTooltip>
119+
) : (
120+
child
121+
)
122+
}
123+
124+
export default OverflowText
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
.ellipsis {
2+
display: inline-block;
3+
overflow: hidden;
4+
text-overflow: ellipsis;
5+
white-space: nowrap;
6+
max-width: 100%;
7+
}
8+
9+
.multiLine {
10+
display: -webkit-box;
11+
-webkit-box-orient: vertical;
12+
overflow: hidden;
13+
white-space: normal;
14+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { Meta, Canvas, Story, ArgTypes } from '@storybook/addon-docs/blocks';
2+
import * as Stories from './PortalTooltip.stories';
3+
4+
<Meta of={Stories} />
5+
6+
# PortalTooltip (门户提示框)
7+
8+
一个轻量级的 Tooltip 组件,将内容渲染到 Portal (`document.body`) 中。这避免了在滚动容器或复杂布局中使用时出现的 z-index 或被裁剪的问题。
9+
10+
## 参数属性 (Props)
11+
12+
<ArgTypes />
13+
14+
## 示例
15+
16+
### 默认用法
17+
18+
<Canvas>
19+
<Story name="Default" />
20+
</Canvas>
21+
22+
### 自定义宽度
23+
24+
你可以通过 `overlayStyle` 自定义 Tooltip 的最大宽度。默认为 `300px`
25+
26+
<Canvas>
27+
<Story name="Custom MaxWidth (500px)" />
28+
</Canvas>
29+
30+
### 所有位置方向
31+
32+
支持 `top`, `bottom`, `left`, `right` 以及各个角落的变体。
33+
34+
<Canvas>
35+
<Story name="Placements" />
36+
</Canvas>
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import React from 'react'
2+
import PortalTooltip from './index'
3+
4+
export default {
5+
title: 'Stateless/PortalTooltip',
6+
component: PortalTooltip,
7+
argTypes: {
8+
placement: {
9+
control: { type: 'select' },
10+
options: ['top', 'bottom', 'left', 'right', 'topLeft', 'topRight', 'bottomLeft', 'bottomRight'],
11+
description: 'The position of the tooltip relative to the target.',
12+
},
13+
trigger: {
14+
control: { type: 'radio' },
15+
options: ['hover', 'click'],
16+
description: 'The trigger mode which executes the tooltip action.',
17+
},
18+
title: {
19+
control: 'text',
20+
description: 'The text shown in the tooltip.',
21+
},
22+
overlayStyle: {
23+
control: 'object',
24+
description: 'Style properties for the tooltip overlay (e.g. maxWidth).',
25+
},
26+
},
27+
}
28+
29+
const Template = (args) => (
30+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '200px' }}>
31+
<PortalTooltip {...args}>
32+
<button style={{ padding: '8px 16px', cursor: 'pointer' }}>Hover me</button>
33+
</PortalTooltip>
34+
</div>
35+
)
36+
37+
export const Default = Template.bind({})
38+
Default.args = {
39+
title: 'This is a tooltip text',
40+
placement: 'top',
41+
trigger: 'hover',
42+
}
43+
44+
export const CustomWidth = Template.bind({})
45+
CustomWidth.args = {
46+
title: 'This is a very long tooltip text that would normally be constrained to 300px width but here we override it.',
47+
placement: 'top',
48+
trigger: 'hover',
49+
overlayStyle: { maxWidth: 500 },
50+
}
51+
CustomWidth.storyName = 'Custom MaxWidth (500px)'
52+
53+
export const Placements = () => (
54+
<div style={{ padding: '60px 100px' }}>
55+
<div style={{ display: 'flex', gap: '20px', marginBottom: '40px' }}>
56+
<PortalTooltip title="Top Left" placement="topLeft">
57+
<button>TL</button>
58+
</PortalTooltip>
59+
<PortalTooltip title="Top" placement="top">
60+
<button>Top</button>
61+
</PortalTooltip>
62+
<PortalTooltip title="Top Right" placement="topRight">
63+
<button>TR</button>
64+
</PortalTooltip>
65+
</div>
66+
<div style={{ display: 'flex', gap: '20px', marginBottom: '40px' }}>
67+
<PortalTooltip title="Left" placement="left">
68+
<button>Left</button>
69+
</PortalTooltip>
70+
<div style={{ width: '50px' }} />
71+
<PortalTooltip title="Right" placement="right">
72+
<button>Right</button>
73+
</PortalTooltip>
74+
</div>
75+
<div style={{ display: 'flex', gap: '20px' }}>
76+
<PortalTooltip title="Bottom Left" placement="bottomLeft">
77+
<button>BL</button>
78+
</PortalTooltip>
79+
<PortalTooltip title="Bottom" placement="bottom">
80+
<button>Bottom</button>
81+
</PortalTooltip>
82+
<PortalTooltip title="Bottom Right" placement="bottomRight">
83+
<button>BR</button>
84+
</PortalTooltip>
85+
</div>
86+
</div>
87+
)

0 commit comments

Comments
 (0)