@seed-design/react-middle-truncate 0.0.0-alpha-20260416134952

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ 'use client';
2
+ var jsxRuntime = require('react/jsx-runtime');
3
+ var reactPrimitive = require('@seed-design/react-primitive');
4
+ var domUtils = require('@seed-design/dom-utils');
5
+ var react = require('react');
6
+ var reactComposeRefs = require('@radix-ui/react-compose-refs');
7
+ var useMiddleTruncate12s = require('./useMiddleTruncate-12s-PfK603TV.cjs');
8
+
9
+ // Visual behavior is verified in Storybook: docs/stories/MiddleTruncate.stories.tsx
10
+
11
+ const MiddleTruncate = /*#__PURE__*/ react.forwardRef(({ children, end, ellipsis, maxLines, onTruncate, ...otherProps }, ref)=>{
12
+ const api = useMiddleTruncate12s.useMiddleTruncate({
13
+ children,
14
+ end,
15
+ ellipsis,
16
+ maxLines,
17
+ onTruncate
18
+ });
19
+ return /*#__PURE__*/ jsxRuntime.jsx(reactPrimitive.Primitive.span, {
20
+ ref: reactComposeRefs.composeRefs(ref, api.contentRef),
21
+ ...domUtils.mergeProps(api.contentProps, otherProps)
22
+ });
23
+ });
24
+ MiddleTruncate.displayName = "MiddleTruncate";
25
+
26
+ exports.MiddleTruncate = MiddleTruncate;
@@ -0,0 +1,26 @@
1
+ 'use client';
2
+ import { jsx } from 'react/jsx-runtime';
3
+ import { Primitive } from '@seed-design/react-primitive';
4
+ import { mergeProps } from '@seed-design/dom-utils';
5
+ import { forwardRef } from 'react';
6
+ import { composeRefs } from '@radix-ui/react-compose-refs';
7
+ import { u as useMiddleTruncate } from './useMiddleTruncate-12s-DT5caddu.js';
8
+
9
+ // Visual behavior is verified in Storybook: docs/stories/MiddleTruncate.stories.tsx
10
+
11
+ const MiddleTruncate = /*#__PURE__*/ forwardRef(({ children, end, ellipsis, maxLines, onTruncate, ...otherProps }, ref)=>{
12
+ const api = useMiddleTruncate({
13
+ children,
14
+ end,
15
+ ellipsis,
16
+ maxLines,
17
+ onTruncate
18
+ });
19
+ return /*#__PURE__*/ jsx(Primitive.span, {
20
+ ref: composeRefs(ref, api.contentRef),
21
+ ...mergeProps(api.contentProps, otherProps)
22
+ });
23
+ });
24
+ MiddleTruncate.displayName = "MiddleTruncate";
25
+
26
+ export { MiddleTruncate as M };
package/lib/index.cjs ADDED
@@ -0,0 +1,5 @@
1
+ var MiddleTruncate12s = require('./MiddleTruncate-12s-BfA2DiuS.cjs');
2
+
3
+
4
+
5
+ exports.MiddleTruncate = MiddleTruncate12s.MiddleTruncate;
package/lib/index.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ import { PrimitiveProps } from '@seed-design/react-primitive';
2
+ import * as react from 'react';
3
+
4
+ interface UseMiddleTruncateProps {
5
+ children: string;
6
+ /**
7
+ * Number of characters to preserve from the end of the text.
8
+ * @default 0
9
+ */
10
+ end?: number;
11
+ /**
12
+ * @default "…"
13
+ */
14
+ ellipsis?: string;
15
+ /**
16
+ * @default 1
17
+ */
18
+ maxLines?: number;
19
+ onTruncate?: (isTruncated: boolean) => void;
20
+ }
21
+
22
+ interface MiddleTruncateProps extends UseMiddleTruncateProps, PrimitiveProps, Omit<react.HTMLAttributes<HTMLSpanElement>, "children"> {
23
+ }
24
+ declare const MiddleTruncate: react.ForwardRefExoticComponent<MiddleTruncateProps & react.RefAttributes<HTMLSpanElement>>;
25
+
26
+ export { MiddleTruncate };
27
+ export type { MiddleTruncateProps };
package/lib/index.js ADDED
@@ -0,0 +1 @@
1
+ export { M as MiddleTruncate } from './MiddleTruncate-12s-CnVju8qr.js';
@@ -0,0 +1,124 @@
1
+ 'use client';
2
+ import { elementProps } from '@seed-design/dom-utils';
3
+ import { useRef, useState, useCallback, useEffect } from 'react';
4
+
5
+ // Visual behavior is verified in Storybook: docs/stories/MiddleTruncate.stories.tsx
6
+
7
+ function useMiddleTruncate(props) {
8
+ const { children, end = 0, ellipsis = "…", maxLines = 1, onTruncate } = props;
9
+ const contentRef = useRef(null);
10
+ const [displayText, setDisplayText] = useState(null);
11
+ const compute = useCallback(()=>{
12
+ const el = contentRef.current;
13
+ if (!el || !children) {
14
+ setDisplayText(null);
15
+ onTruncate?.(false);
16
+ return;
17
+ }
18
+ const parent = el.parentElement;
19
+ if (!parent) return;
20
+ const computed = getComputedStyle(parent);
21
+ const contentWidth = Math.floor(parent.clientWidth - (Number.parseFloat(computed.paddingLeft) || 0) - (Number.parseFloat(computed.paddingRight) || 0));
22
+ if (contentWidth <= 0) {
23
+ setDisplayText(null);
24
+ onTruncate?.(false);
25
+ return;
26
+ }
27
+ const measurer = document.createElement("span");
28
+ measurer.style.cssText = [
29
+ "position:absolute",
30
+ "visibility:hidden",
31
+ "pointer-events:none",
32
+ "display:block",
33
+ `width:${contentWidth}px`,
34
+ `font:${computed.font}`,
35
+ `letter-spacing:${computed.letterSpacing}`,
36
+ "word-break:break-all",
37
+ `line-height:${computed.lineHeight}`,
38
+ "white-space:nowrap"
39
+ ].join(";");
40
+ measurer.textContent = children;
41
+ document.body.appendChild(measurer);
42
+ const lineHeight = measurer.scrollHeight;
43
+ const maxHeight = lineHeight * maxLines;
44
+ measurer.style.whiteSpace = "normal";
45
+ // text fits without truncation
46
+ if (measurer.scrollHeight <= maxHeight + 1) {
47
+ setDisplayText(children);
48
+ onTruncate?.(false);
49
+ measurer.remove();
50
+ return;
51
+ }
52
+ // binary search
53
+ const safeEnd = Math.min(Math.abs(end), children.length);
54
+ const endFragment = safeEnd > 0 ? children.slice(-safeEnd) : "";
55
+ const startSource = safeEnd > 0 ? children.slice(0, -safeEnd) : children;
56
+ // If ellipsis + endFragment alone overflows, reduce endFragment first
57
+ measurer.textContent = ellipsis + endFragment;
58
+ if (measurer.scrollHeight > maxHeight + 1) {
59
+ let eLow = 0;
60
+ let eHigh = endFragment.length;
61
+ while(eLow <= eHigh){
62
+ const eMid = Math.floor((eLow + eHigh) / 2);
63
+ measurer.textContent = ellipsis + endFragment.slice(-eMid || undefined);
64
+ if (measurer.scrollHeight <= maxHeight + 1) {
65
+ eLow = eMid + 1;
66
+ continue;
67
+ }
68
+ eHigh = eMid - 1;
69
+ }
70
+ const trimmedEnd = eHigh > 0 ? endFragment.slice(-eHigh) : "";
71
+ setDisplayText(ellipsis + trimmedEnd);
72
+ onTruncate?.(true);
73
+ measurer.remove();
74
+ return;
75
+ }
76
+ let low = 0;
77
+ let high = startSource.length;
78
+ while(low <= high){
79
+ const mid = Math.floor((low + high) / 2);
80
+ measurer.textContent = startSource.slice(0, mid) + ellipsis + endFragment;
81
+ if (measurer.scrollHeight <= maxHeight + 1) {
82
+ low = mid + 1;
83
+ continue;
84
+ }
85
+ high = mid - 1;
86
+ }
87
+ const startFragment = startSource.slice(0, Math.max(high, 0));
88
+ setDisplayText(startFragment + ellipsis + endFragment);
89
+ onTruncate?.(true);
90
+ measurer.remove();
91
+ }, [
92
+ children,
93
+ end,
94
+ ellipsis,
95
+ maxLines,
96
+ onTruncate
97
+ ]);
98
+ useEffect(()=>{
99
+ compute();
100
+ }, [
101
+ compute
102
+ ]);
103
+ // recompute on resize
104
+ useEffect(()=>{
105
+ const parent = contentRef.current?.parentElement;
106
+ if (!parent) return;
107
+ const observer = new ResizeObserver(()=>compute());
108
+ observer.observe(parent);
109
+ return ()=>observer.disconnect();
110
+ }, [
111
+ compute
112
+ ]);
113
+ return {
114
+ contentRef,
115
+ contentProps: elementProps({
116
+ style: {
117
+ wordBreak: "break-all"
118
+ },
119
+ children: displayText ?? children
120
+ })
121
+ };
122
+ }
123
+
124
+ export { useMiddleTruncate as u };
@@ -0,0 +1,124 @@
1
+ 'use client';
2
+ var domUtils = require('@seed-design/dom-utils');
3
+ var react = require('react');
4
+
5
+ // Visual behavior is verified in Storybook: docs/stories/MiddleTruncate.stories.tsx
6
+
7
+ function useMiddleTruncate(props) {
8
+ const { children, end = 0, ellipsis = "…", maxLines = 1, onTruncate } = props;
9
+ const contentRef = react.useRef(null);
10
+ const [displayText, setDisplayText] = react.useState(null);
11
+ const compute = react.useCallback(()=>{
12
+ const el = contentRef.current;
13
+ if (!el || !children) {
14
+ setDisplayText(null);
15
+ onTruncate?.(false);
16
+ return;
17
+ }
18
+ const parent = el.parentElement;
19
+ if (!parent) return;
20
+ const computed = getComputedStyle(parent);
21
+ const contentWidth = Math.floor(parent.clientWidth - (Number.parseFloat(computed.paddingLeft) || 0) - (Number.parseFloat(computed.paddingRight) || 0));
22
+ if (contentWidth <= 0) {
23
+ setDisplayText(null);
24
+ onTruncate?.(false);
25
+ return;
26
+ }
27
+ const measurer = document.createElement("span");
28
+ measurer.style.cssText = [
29
+ "position:absolute",
30
+ "visibility:hidden",
31
+ "pointer-events:none",
32
+ "display:block",
33
+ `width:${contentWidth}px`,
34
+ `font:${computed.font}`,
35
+ `letter-spacing:${computed.letterSpacing}`,
36
+ "word-break:break-all",
37
+ `line-height:${computed.lineHeight}`,
38
+ "white-space:nowrap"
39
+ ].join(";");
40
+ measurer.textContent = children;
41
+ document.body.appendChild(measurer);
42
+ const lineHeight = measurer.scrollHeight;
43
+ const maxHeight = lineHeight * maxLines;
44
+ measurer.style.whiteSpace = "normal";
45
+ // text fits without truncation
46
+ if (measurer.scrollHeight <= maxHeight + 1) {
47
+ setDisplayText(children);
48
+ onTruncate?.(false);
49
+ measurer.remove();
50
+ return;
51
+ }
52
+ // binary search
53
+ const safeEnd = Math.min(Math.abs(end), children.length);
54
+ const endFragment = safeEnd > 0 ? children.slice(-safeEnd) : "";
55
+ const startSource = safeEnd > 0 ? children.slice(0, -safeEnd) : children;
56
+ // If ellipsis + endFragment alone overflows, reduce endFragment first
57
+ measurer.textContent = ellipsis + endFragment;
58
+ if (measurer.scrollHeight > maxHeight + 1) {
59
+ let eLow = 0;
60
+ let eHigh = endFragment.length;
61
+ while(eLow <= eHigh){
62
+ const eMid = Math.floor((eLow + eHigh) / 2);
63
+ measurer.textContent = ellipsis + endFragment.slice(-eMid || undefined);
64
+ if (measurer.scrollHeight <= maxHeight + 1) {
65
+ eLow = eMid + 1;
66
+ continue;
67
+ }
68
+ eHigh = eMid - 1;
69
+ }
70
+ const trimmedEnd = eHigh > 0 ? endFragment.slice(-eHigh) : "";
71
+ setDisplayText(ellipsis + trimmedEnd);
72
+ onTruncate?.(true);
73
+ measurer.remove();
74
+ return;
75
+ }
76
+ let low = 0;
77
+ let high = startSource.length;
78
+ while(low <= high){
79
+ const mid = Math.floor((low + high) / 2);
80
+ measurer.textContent = startSource.slice(0, mid) + ellipsis + endFragment;
81
+ if (measurer.scrollHeight <= maxHeight + 1) {
82
+ low = mid + 1;
83
+ continue;
84
+ }
85
+ high = mid - 1;
86
+ }
87
+ const startFragment = startSource.slice(0, Math.max(high, 0));
88
+ setDisplayText(startFragment + ellipsis + endFragment);
89
+ onTruncate?.(true);
90
+ measurer.remove();
91
+ }, [
92
+ children,
93
+ end,
94
+ ellipsis,
95
+ maxLines,
96
+ onTruncate
97
+ ]);
98
+ react.useEffect(()=>{
99
+ compute();
100
+ }, [
101
+ compute
102
+ ]);
103
+ // recompute on resize
104
+ react.useEffect(()=>{
105
+ const parent = contentRef.current?.parentElement;
106
+ if (!parent) return;
107
+ const observer = new ResizeObserver(()=>compute());
108
+ observer.observe(parent);
109
+ return ()=>observer.disconnect();
110
+ }, [
111
+ compute
112
+ ]);
113
+ return {
114
+ contentRef,
115
+ contentProps: domUtils.elementProps({
116
+ style: {
117
+ wordBreak: "break-all"
118
+ },
119
+ children: displayText ?? children
120
+ })
121
+ };
122
+ }
123
+
124
+ exports.useMiddleTruncate = useMiddleTruncate;
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@seed-design/react-middle-truncate",
3
+ "version": "0.0.0-alpha-20260416134952",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/daangn/seed-design.git",
7
+ "directory": "packages/react-headless/middle-truncate"
8
+ },
9
+ "sideEffects": false,
10
+ "type": "module",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./lib/index.d.ts",
14
+ "import": "./lib/index.js",
15
+ "require": "./lib/index.cjs"
16
+ },
17
+ "./package.json": "./package.json"
18
+ },
19
+ "main": "./lib/index.cjs",
20
+ "files": [
21
+ "lib",
22
+ "src"
23
+ ],
24
+ "scripts": {
25
+ "clean": "rm -rf lib",
26
+ "build": "bunchee",
27
+ "lint:publish": "bun publint"
28
+ },
29
+ "devDependencies": {
30
+ "@testing-library/react": "^16.3.0",
31
+ "@testing-library/jest-dom": "^6.6.3",
32
+ "@types/react": "^19.1.6",
33
+ "react": "^19.1.0",
34
+ "react-dom": "^19.1.0"
35
+ },
36
+ "peerDependencies": {
37
+ "react": ">=18.0.0",
38
+ "react-dom": ">=18.0.0"
39
+ },
40
+ "dependencies": {
41
+ "@radix-ui/react-compose-refs": "^1.1.2",
42
+ "@seed-design/dom-utils": "1.0.0",
43
+ "@seed-design/react-primitive": "0.0.0-alpha-20260414104312"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ }
48
+ }
@@ -0,0 +1,30 @@
1
+ // Visual behavior is verified in Storybook: docs/stories/MiddleTruncate.stories.tsx
2
+
3
+ "use client";
4
+
5
+ import { Primitive, type PrimitiveProps } from "@seed-design/react-primitive";
6
+ import { mergeProps } from "@seed-design/dom-utils";
7
+ import type * as React from "react";
8
+ import { forwardRef } from "react";
9
+ import { composeRefs } from "@radix-ui/react-compose-refs";
10
+ import type { UseMiddleTruncateProps } from "./useMiddleTruncate";
11
+ import { useMiddleTruncate } from "./useMiddleTruncate";
12
+
13
+ export interface MiddleTruncateProps
14
+ extends UseMiddleTruncateProps,
15
+ PrimitiveProps,
16
+ Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> {}
17
+
18
+ export const MiddleTruncate = forwardRef<HTMLSpanElement, MiddleTruncateProps>(
19
+ ({ children, end, ellipsis, maxLines, onTruncate, ...otherProps }, ref) => {
20
+ const api = useMiddleTruncate({ children, end, ellipsis, maxLines, onTruncate });
21
+
22
+ return (
23
+ <Primitive.span
24
+ ref={composeRefs(ref, api.contentRef)}
25
+ {...mergeProps(api.contentProps, otherProps)}
26
+ />
27
+ );
28
+ },
29
+ );
30
+ MiddleTruncate.displayName = "MiddleTruncate";
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { MiddleTruncate, type MiddleTruncateProps } from "./MiddleTruncate";
@@ -0,0 +1,169 @@
1
+ // Visual behavior is verified in Storybook: docs/stories/MiddleTruncate.stories.tsx
2
+
3
+ "use client";
4
+
5
+ import { elementProps } from "@seed-design/dom-utils";
6
+ import { useCallback, useEffect, useRef, useState } from "react";
7
+
8
+ export interface UseMiddleTruncateProps {
9
+ children: string;
10
+
11
+ /**
12
+ * Number of characters to preserve from the end of the text.
13
+ * @default 0
14
+ */
15
+ end?: number;
16
+
17
+ /**
18
+ * @default "…"
19
+ */
20
+ ellipsis?: string;
21
+
22
+ /**
23
+ * @default 1
24
+ */
25
+ maxLines?: number;
26
+
27
+ onTruncate?: (isTruncated: boolean) => void;
28
+ }
29
+
30
+ export function useMiddleTruncate(props: UseMiddleTruncateProps) {
31
+ const { children, end = 0, ellipsis = "…", maxLines = 1, onTruncate } = props;
32
+
33
+ const contentRef = useRef<HTMLElement | null>(null);
34
+ const [displayText, setDisplayText] = useState<string | null>(null);
35
+
36
+ const compute = useCallback(() => {
37
+ const el = contentRef.current;
38
+ if (!el || !children) {
39
+ setDisplayText(null);
40
+ onTruncate?.(false);
41
+ return;
42
+ }
43
+
44
+ const parent = el.parentElement;
45
+ if (!parent) return;
46
+
47
+ const computed = getComputedStyle(parent);
48
+
49
+ const contentWidth = Math.floor(
50
+ parent.clientWidth -
51
+ (Number.parseFloat(computed.paddingLeft) || 0) -
52
+ (Number.parseFloat(computed.paddingRight) || 0),
53
+ );
54
+
55
+ if (contentWidth <= 0) {
56
+ setDisplayText(null);
57
+ onTruncate?.(false);
58
+ return;
59
+ }
60
+
61
+ const measurer = document.createElement("span");
62
+
63
+ measurer.style.cssText = [
64
+ "position:absolute",
65
+ "visibility:hidden",
66
+ "pointer-events:none",
67
+ "display:block",
68
+ `width:${contentWidth}px`,
69
+ `font:${computed.font}`,
70
+ `letter-spacing:${computed.letterSpacing}`,
71
+ "word-break:break-all",
72
+ `line-height:${computed.lineHeight}`,
73
+ "white-space:nowrap",
74
+ ].join(";");
75
+ measurer.textContent = children;
76
+ document.body.appendChild(measurer);
77
+
78
+ const lineHeight = measurer.scrollHeight;
79
+ const maxHeight = lineHeight * maxLines;
80
+
81
+ measurer.style.whiteSpace = "normal";
82
+
83
+ // text fits without truncation
84
+ if (measurer.scrollHeight <= maxHeight + 1) {
85
+ setDisplayText(children);
86
+ onTruncate?.(false);
87
+ measurer.remove();
88
+ return;
89
+ }
90
+
91
+ // binary search
92
+ const safeEnd = Math.min(Math.abs(end), children.length);
93
+ const endFragment = safeEnd > 0 ? children.slice(-safeEnd) : "";
94
+ const startSource = safeEnd > 0 ? children.slice(0, -safeEnd) : children;
95
+
96
+ // If ellipsis + endFragment alone overflows, reduce endFragment first
97
+ measurer.textContent = ellipsis + endFragment;
98
+ if (measurer.scrollHeight > maxHeight + 1) {
99
+ let eLow = 0;
100
+ let eHigh = endFragment.length;
101
+
102
+ while (eLow <= eHigh) {
103
+ const eMid = Math.floor((eLow + eHigh) / 2);
104
+ measurer.textContent = ellipsis + endFragment.slice(-eMid || undefined);
105
+
106
+ if (measurer.scrollHeight <= maxHeight + 1) {
107
+ eLow = eMid + 1;
108
+ continue;
109
+ }
110
+
111
+ eHigh = eMid - 1;
112
+ }
113
+
114
+ const trimmedEnd = eHigh > 0 ? endFragment.slice(-eHigh) : "";
115
+
116
+ setDisplayText(ellipsis + trimmedEnd);
117
+ onTruncate?.(true);
118
+ measurer.remove();
119
+
120
+ return;
121
+ }
122
+
123
+ let low = 0;
124
+ let high = startSource.length;
125
+
126
+ while (low <= high) {
127
+ const mid = Math.floor((low + high) / 2);
128
+ measurer.textContent = startSource.slice(0, mid) + ellipsis + endFragment;
129
+
130
+ if (measurer.scrollHeight <= maxHeight + 1) {
131
+ low = mid + 1;
132
+ continue;
133
+ }
134
+
135
+ high = mid - 1;
136
+ }
137
+
138
+ const startFragment = startSource.slice(0, Math.max(high, 0));
139
+
140
+ setDisplayText(startFragment + ellipsis + endFragment);
141
+ onTruncate?.(true);
142
+ measurer.remove();
143
+ }, [children, end, ellipsis, maxLines, onTruncate]);
144
+
145
+ useEffect(() => {
146
+ compute();
147
+ }, [compute]);
148
+
149
+ // recompute on resize
150
+ useEffect(() => {
151
+ const parent = contentRef.current?.parentElement;
152
+ if (!parent) return;
153
+
154
+ const observer = new ResizeObserver(() => compute());
155
+ observer.observe(parent);
156
+
157
+ return () => observer.disconnect();
158
+ }, [compute]);
159
+
160
+ return {
161
+ contentRef,
162
+ contentProps: elementProps({
163
+ style: {
164
+ wordBreak: "break-all",
165
+ },
166
+ children: displayText ?? children,
167
+ }),
168
+ };
169
+ }