Skip to content

Commit 4bde7cf

Browse files
authored
chore: merge pull request #59 from RostyslavNihrutsa/develop
Truncate fixing
2 parents 5e6105c + bfe60ca commit 4bde7cf

6 files changed

Lines changed: 163 additions & 61 deletions

File tree

src/components/Select/Select.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {Root, SelectProps} from "@radix-ui/react-select";
66

77
import {useComponentProps} from "../../providers";
88

9-
export {SelectProps};
9+
export {type SelectProps};
1010

1111
const Select: FC<SelectProps> = props => {
1212
const {...other} = {...useComponentProps("select"), ...props};

src/components/Select/SelectIcon.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {useComponentProps} from "../../providers";
88

99
import styles from "./select.module.scss";
1010

11-
export {SelectIconProps};
11+
export {type SelectIconProps};
1212

1313
const SelectIcon: ForwardRefRenderFunction<HTMLSpanElement, SelectIconProps> = (props, ref) => {
1414
const {className, ...other} = {...useComponentProps("selectIcon"), ...props};

src/components/Select/SelectValue.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {useComponentProps} from "../../providers";
88

99
import styles from "./select.module.scss";
1010

11-
export {SelectValueProps};
11+
export {type SelectValueProps};
1212

1313
const SelectValue: ForwardRefRenderFunction<HTMLSpanElement, SelectValueProps> = (props, ref) => {
1414
const {className, ...other} = {...useComponentProps("selectValue"), ...props};

src/components/Truncate/Truncate.stories.tsx

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@ import React, {useState} from "react";
33
import {Meta, StoryObj} from "@storybook/react";
44

55
import TruncateComponent, {TruncateProps} from "./Truncate";
6-
import {Header, ScrollArea, TextField} from "../index";
7-
import {ViewportProvider} from "../Viewport";
6+
import {Header, Highlight, ScrollArea, TextField, ViewportProvider} from "../index";
87

98
const list = [
109
{
@@ -68,6 +67,49 @@ export const Truncate: StoryObj<TruncateProps> = {
6867
render: props => <TruncateStoryRender {...props} />,
6968
};
7069

70+
export const Inline: StoryObj<TruncateProps> = {
71+
args: {
72+
text: "Very long text that should be truncated to fit in line with a button",
73+
middle: true,
74+
},
75+
76+
render: props => (
77+
<div style={{display: "flex", flexDirection: "column", gap: "20px"}}>
78+
<Header title="Inline Truncate with Button (flex-start)" />
79+
<div
80+
style={{
81+
display: "flex",
82+
alignItems: "center",
83+
border: "1px solid #ccc",
84+
padding: "10px",
85+
width: "300px",
86+
resize: "horizontal",
87+
overflow: "auto",
88+
}}
89+
>
90+
<TruncateComponent {...props} style={{flexShrink: 1}} />
91+
<button style={{flexShrink: 0, marginLeft: "10px"}}>Button</button>
92+
</div>
93+
94+
<Header title="Inline Truncate with Button (always follows)" />
95+
<div
96+
style={{
97+
display: "flex",
98+
alignItems: "center",
99+
border: "1px solid #ccc",
100+
padding: "10px",
101+
width: "400px",
102+
resize: "horizontal",
103+
overflow: "auto",
104+
}}
105+
>
106+
<TruncateComponent {...props} style={{flexShrink: 1}} />
107+
<button style={{flexShrink: 0, marginLeft: "10px"}}>Action</button>
108+
</div>
109+
</div>
110+
),
111+
};
112+
71113
const TruncateStoryRender = (props: TruncateProps) => {
72114
const [searchWords, setSearchWords] = useState("");
73115

@@ -112,8 +154,19 @@ const TruncateStoryRender = (props: TruncateProps) => {
112154
<ScrollArea style={{borderTop: "1px solid #ccc"}}>
113155
{filteredItems.map(({title, url}) => (
114156
<div key={url} style={{borderBottom: "1px solid #ccc", padding: "10px"}}>
115-
<TruncateComponent text={title} highlight={{searchWords}} />
116-
<TruncateComponent text={url} highlight={{searchWords}} middle />
157+
<TruncateComponent
158+
text={title}
159+
render={text => <Highlight textToHighlight={text} searchWords={[searchWords]} />}
160+
/>
161+
<div style={{display: "flex", alignItems: "center", minWidth: 0}}>
162+
<TruncateComponent
163+
text={url}
164+
render={text => <Highlight textToHighlight={text} searchWords={[searchWords]} />}
165+
middle
166+
style={{flexShrink: 1}}
167+
/>
168+
<button style={{flexShrink: 0, marginLeft: "8px"}}>Button</button>
169+
</div>
117170
</div>
118171
))}
119172
</ScrollArea>

src/components/Truncate/Truncate.tsx

Lines changed: 86 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -5,53 +5,84 @@ import React, {
55
memo,
66
useImperativeHandle,
77
useLayoutEffect,
8-
useMemo,
98
useRef,
109
useState,
1110
} from "react";
12-
import debounce from "debounce";
1311
import classnames from "classnames";
1412

1513
import {useComponentProps} from "../../providers";
1614

17-
import {Highlight, HighlightProps} from "../Highlight";
18-
1915
import styles from "./truncate.module.scss";
2016

2117
export interface TruncateProps extends ComponentProps<"span"> {
2218
text?: string;
2319
middle?: boolean;
2420
separator?: string;
25-
highlight?: Omit<HighlightProps, "textToHighlight">;
21+
contentClassname?: string;
22+
render?: (text: string) => React.ReactNode;
2623
}
2724

28-
const trimMiddle = (el: HTMLElement, text: string, separator: string) => {
29-
const measure = (txt: string) => {
30-
el.textContent = txt;
31-
return el.scrollWidth <= el.clientWidth;
32-
};
25+
const MAX_CACHE_SIZE = 1000;
26+
const cache = new Map<string, string>();
27+
let canvas: HTMLCanvasElement | null = null;
28+
29+
const addToCache = (key: string, value: string) => {
30+
if (cache.size >= MAX_CACHE_SIZE) {
31+
const oldestKey = cache.keys().next().value;
32+
if (oldestKey !== undefined) {
33+
cache.delete(oldestKey);
34+
}
35+
}
36+
cache.set(key, value);
37+
};
38+
39+
const calculateMiddleTruncate = (
40+
text: string,
41+
maxWidth: number,
42+
font: string,
43+
letterSpacing: string,
44+
separator: string
45+
) => {
46+
const cacheKey = `${text}-${maxWidth}-${font}-${letterSpacing}-${separator}`;
47+
if (cache.has(cacheKey)) return cache.get(cacheKey)!;
48+
49+
if (!canvas) {
50+
canvas = document.createElement("canvas");
51+
}
52+
const context = canvas.getContext("2d");
53+
if (!context) return text;
54+
context.font = font;
55+
context.letterSpacing = letterSpacing;
56+
57+
const measure = (txt: string) => context.measureText(txt).width;
3358

34-
if (measure(text)) return text;
59+
if (measure(text) <= maxWidth) {
60+
addToCache(cacheKey, text);
61+
return text;
62+
}
3563

3664
let low = 0;
37-
let high = text.length - 2;
65+
let high = text.length;
3866
let result = "";
3967

4068
while (low <= high) {
41-
const size = Math.floor((low + high) / 2);
42-
const left = text.slice(0, Math.ceil(size / 2));
43-
const right = text.slice(text.length - Math.floor(size / 2));
44-
const trimmed = left + separator + right;
69+
const mid = Math.floor((low + high) / 2);
70+
const leftHalf = Math.ceil(mid / 2);
71+
const rightHalf = Math.floor(mid / 2);
72+
73+
const trimmed = text.slice(0, leftHalf) + separator + text.slice(text.length - rightHalf);
4574

46-
if (measure(trimmed)) {
75+
if (measure(trimmed) <= maxWidth) {
4776
result = trimmed;
48-
low = size + 1;
77+
low = mid + 1;
4978
} else {
50-
high = size - 1;
79+
high = mid - 1;
5180
}
5281
}
5382

54-
return result || text.charAt(0) + separator + text.charAt(text.length - 1);
83+
const finalResult = result || text[0] + separator + text.slice(-1);
84+
addToCache(cacheKey, finalResult);
85+
return finalResult;
5586
};
5687

5788
const Truncate: ForwardRefRenderFunction<HTMLSpanElement, TruncateProps> = (props, ref) => {
@@ -60,55 +91,67 @@ const Truncate: ForwardRefRenderFunction<HTMLSpanElement, TruncateProps> = (prop
6091
middle,
6192
separator = "...",
6293
className,
63-
highlight,
94+
contentClassname,
95+
render,
6496
...other
6597
} = {...useComponentProps("truncate"), ...props};
6698

67-
const innerRef = useRef<HTMLSpanElement | null>(null);
99+
const containerRef = useRef<HTMLSpanElement>(null);
68100
const [displayedText, setDisplayedText] = useState(text);
69101

70-
const finalText = useMemo(() => {
71-
return middle ? displayedText : text;
72-
}, [displayedText, text, middle]);
73-
74-
useImperativeHandle(ref, () => innerRef.current!, []);
102+
useImperativeHandle(ref, () => containerRef.current!, []);
75103

76104
useLayoutEffect(() => {
77-
const el = innerRef.current;
78-
if (!el || !middle) return;
105+
const el = containerRef.current;
106+
107+
if (!middle || !el) {
108+
setDisplayedText(text);
109+
return;
110+
}
111+
112+
const observer = new ResizeObserver(entries => {
113+
const entry = entries[0];
114+
if (!entry) return;
79115

80-
let observer: ResizeObserver | null = null;
116+
const maxWidth = entry.contentRect.width;
117+
const {fontWeight, fontSize, fontFamily, letterSpacing} = window.getComputedStyle(el);
81118

82-
const measureAndTrim = debounce(() => {
83-
setDisplayedText(trimMiddle(el, text, separator));
84-
}, 150);
119+
const font = [fontWeight, fontSize, fontFamily].join(" ");
85120

86-
measureAndTrim();
121+
const truncated = calculateMiddleTruncate(text, maxWidth, font, letterSpacing, separator);
87122

88-
observer = new ResizeObserver(() => measureAndTrim());
123+
setDisplayedText(prev => (prev !== truncated ? truncated : prev));
124+
});
89125

90126
observer.observe(el);
127+
return () => observer.disconnect();
128+
}, [text, middle, separator]);
91129

92-
return () => {
93-
measureAndTrim.clear();
94-
observer?.disconnect();
95-
};
96-
}, [text, separator, middle]);
130+
const content = render ? render(displayedText) : displayedText;
97131

98132
return (
99133
<span
134+
ref={containerRef}
100135
className={classnames(
101136
styles["truncate"],
102137
{
103138
[styles["truncate--middle"]]: middle,
104139
},
105140
className
106141
)}
142+
title={text}
107143
{...other}
108144
>
109-
<span ref={innerRef} className={styles["truncate__hidden"]} />
110-
111-
{highlight ? <Highlight {...highlight} textToHighlight={finalText} /> : finalText}
145+
{middle ? (
146+
<>
147+
<span className={styles["truncate__hidden"]} aria-hidden="true">
148+
{text}
149+
</span>
150+
<span className={classnames(styles["truncate__content"], contentClassname)}>{content}</span>
151+
</>
152+
) : (
153+
content
154+
)}
112155
</span>
113156
);
114157
};

src/components/Truncate/truncate.module.scss

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,16 @@
22

33
.truncate {
44
position: relative;
5-
display: block;
6-
width: 100%;
5+
display: inline-block;
6+
vertical-align: middle;
7+
width: auto;
8+
max-width: 100%;
9+
min-width: 0;
710
white-space: nowrap;
811
overflow: hidden;
912
text-overflow: ellipsis;
1013
transition: color var(--truncate-speed-color, var(--speed-color));
1114

12-
&__hidden {
13-
opacity: 0;
14-
position: absolute;
15-
width: 100%;
16-
white-space: nowrap;
17-
overflow: hidden;
18-
text-overflow: ellipsis;
19-
}
20-
2115
&--middle {
2216
text-overflow: clip;
2317

@@ -28,4 +22,16 @@
2822
padding-left: var(--truncate-around-space, 8px);
2923
}
3024
}
25+
26+
&__hidden {
27+
display: block;
28+
height: 0;
29+
visibility: hidden;
30+
white-space: nowrap;
31+
overflow: hidden;
32+
}
33+
34+
&__content {
35+
display: block;
36+
}
3137
}

0 commit comments

Comments
 (0)