Skip to content

Commit aa1afc4

Browse files
committed
fix(clamp): bake single-line text-overflow:ellipsis for Safari/Firefox
Firefox and Safari don't honour `text-overflow: ellipsis` inside a <foreignObject>, so single-line truncated text captured without its ellipsis (and overflowed its box). Resolve it up front like the multi-line clamp: a new textEllipsis pass binary-searches the prefix that fits and bakes in the '…', for nowrap, overflow-clipped plain-text containers. Runs in the same subtree walk as lineClamp, sharing one getComputedStyle read per node so the capture hot path isn't regressed. Fixes #431
1 parent be23d82 commit aa1afc4

2 files changed

Lines changed: 125 additions & 11 deletions

File tree

__tests__/module.lineClamp.test.js

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// __tests__/module.lineClamp.test.js – lineClamp coverage
22
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
3-
import { lineClamp, lineClampTree } from '../src/modules/lineClamp.js'
3+
import { lineClamp, lineClampTree, textEllipsis } from '../src/modules/lineClamp.js'
44

55
beforeEach(() => {
66
document.body.innerHTML = ''
@@ -84,6 +84,69 @@ describe('lineClamp', () => {
8484
})
8585
})
8686

87+
describe('textEllipsis (#431)', () => {
88+
const truncated = (whiteSpace, overflow) => {
89+
const div = document.createElement('div')
90+
div.style.cssText = `inline-size:200px;font-size:16px;text-overflow:ellipsis;white-space:${whiteSpace};overflow:${overflow}`
91+
const longText = 'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'
92+
div.textContent = longText
93+
document.body.appendChild(div)
94+
return { div, longText, undo: textEllipsis(div) }
95+
}
96+
97+
it('bakes a single-line ellipsis when text overflows', () => {
98+
const { div, longText, undo } = truncated('nowrap', 'hidden')
99+
expect(div.textContent).toContain('…')
100+
expect(div.textContent.length).toBeLessThan(longText.length)
101+
undo()
102+
expect(div.textContent).toBe(longText)
103+
})
104+
105+
it('is a no-op without text-overflow:ellipsis', () => {
106+
const div = document.createElement('div')
107+
div.style.cssText = 'inline-size:200px;white-space:nowrap;overflow:hidden'
108+
div.textContent = 'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor.'
109+
document.body.appendChild(div)
110+
const before = div.textContent
111+
textEllipsis(div)
112+
expect(div.textContent).toBe(before)
113+
})
114+
115+
it('is a no-op when the text is not clipped to a single line', () => {
116+
// wrapping text (white-space:normal) is not single-line ellipsis
117+
const div = document.createElement('div')
118+
div.style.cssText = 'inline-size:200px;text-overflow:ellipsis;white-space:normal;overflow:hidden'
119+
div.textContent = 'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor.'
120+
document.body.appendChild(div)
121+
const before = div.textContent
122+
textEllipsis(div)
123+
expect(div.textContent).toBe(before)
124+
})
125+
126+
it('is a no-op when content fits', () => {
127+
const div = document.createElement('div')
128+
div.style.cssText = 'inline-size:200px;text-overflow:ellipsis;white-space:nowrap;overflow:hidden'
129+
div.textContent = 'Short'
130+
document.body.appendChild(div)
131+
textEllipsis(div)
132+
expect(div.textContent).toBe('Short')
133+
})
134+
135+
it('lineClampTree bakes single-line ellipsis on descendants', () => {
136+
const outer = document.createElement('div')
137+
const inner = document.createElement('div')
138+
inner.style.cssText = 'inline-size:200px;text-overflow:ellipsis;white-space:nowrap;overflow:hidden'
139+
const longText = 'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt.'
140+
inner.textContent = longText
141+
outer.appendChild(inner)
142+
document.body.appendChild(outer)
143+
const undo = lineClampTree(outer)
144+
expect(inner.textContent).toContain('…')
145+
undo()
146+
expect(inner.textContent).toBe(longText)
147+
})
148+
})
149+
87150
describe('lineClampTree (#386)', () => {
88151
it('clamps nested element with -webkit-line-clamp', () => {
89152
const outer = document.createElement('div')

src/modules/lineClamp.js

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
// src/core/lineClamp.js
22

33
/**
4-
* Apply line-clamp to element AND all descendants that have -webkit-line-clamp.
5-
* Fixes #386: ellipsis now renders for nested elements, not just the root.
4+
* Bake text truncation for the element AND all descendants that CSS would
5+
* truncate: multi-line `-webkit-line-clamp` and single-line
6+
* `text-overflow: ellipsis`. Firefox and Safari don't honour either inside a
7+
* `<foreignObject>`, so we resolve the ellipsis into the text up front.
8+
* Fixes #386 (nested clamp) and #431 (single-line ellipsis on Safari/Firefox).
69
*
710
* @param {Element} el - Root element (and its subtree) to process
811
* @returns {() => void} Combined undo function
@@ -11,8 +14,12 @@ export function lineClampTree(el) {
1114
if (!el) return () => {}
1215
const undos = []
1316
function walk(node) {
14-
const undo = lineClamp(node)
15-
if (undo) undos.push(undo)
17+
// One computed-style read per node, shared by both passes (hot path).
18+
const cs = getComputedStyle(node)
19+
const u1 = lineClamp(node, cs)
20+
if (u1) undos.push(u1)
21+
const u2 = textEllipsis(node, cs)
22+
if (u2) undos.push(u2)
1623
for (const child of node.children || []) walk(child)
1724
}
1825
walk(el)
@@ -26,18 +33,18 @@ export function lineClampTree(el) {
2633
* then returns an undo() that restores everything right after cloning.
2734
*
2835
* @param {Element} el
36+
* @param {CSSStyleDeclaration} [cs]
2937
* @returns {() => void} undo function (no-op if nothing changed)
3038
*/
31-
export function lineClamp(el) {
39+
export function lineClamp(el, cs) {
3240
if (!el) return () => {}
41+
cs = cs || getComputedStyle(el)
3342

34-
const lines = getClamp(el)
43+
const lines = getClamp(cs)
3544
if (lines <= 0) return () => {}
3645

3746
if (!isPlainTextContainer(el)) return () => {}
3847

39-
const cs = getComputedStyle(el)
40-
4148
const original = el.textContent ?? ''
4249
// Guarda para restaurar
4350
const prevText = original
@@ -81,10 +88,54 @@ export function lineClamp(el) {
8188
}
8289
}
8390

91+
/**
92+
* Bake a single-line `text-overflow: ellipsis`. Same strategy as lineClamp but
93+
* on the horizontal axis: only when the element is a nowrap, overflow-clipped
94+
* plain-text container whose content overflows. Firefox/Safari skip this in a
95+
* <foreignObject>, so we resolve it here for every engine (#431).
96+
*
97+
* @param {Element} el
98+
* @param {CSSStyleDeclaration} [cs]
99+
* @returns {() => void} undo function (no-op if nothing changed)
100+
*/
101+
export function textEllipsis(el, cs) {
102+
if (!el) return () => {}
103+
cs = cs || getComputedStyle(el)
104+
105+
if (cs.textOverflow !== 'ellipsis') return () => {}
106+
// Single-line ellipsis: content must not wrap and must be clipped.
107+
if (cs.whiteSpace !== 'nowrap' && cs.whiteSpace !== 'pre') return () => {}
108+
if (cs.overflowX !== 'hidden' && cs.overflowX !== 'clip') return () => {}
109+
110+
if (!isPlainTextContainer(el)) return () => {}
111+
112+
// Ya entra completo → el clamp nativo tampoco haría nada.
113+
if (el.scrollWidth <= el.clientWidth + 0.5) return () => {}
114+
115+
const original = el.textContent ?? ''
116+
const prevText = original
117+
118+
let lo = 0, hi = original.length, best = -1
119+
while (lo <= hi) {
120+
const mid = (lo + hi) >> 1
121+
el.textContent = original.slice(0, mid) + '…'
122+
if (el.scrollWidth <= el.clientWidth + 0.5) {
123+
best = mid; lo = mid + 1
124+
} else {
125+
hi = mid - 1
126+
}
127+
}
128+
129+
el.textContent = (best >= 0 ? original.slice(0, best) : '') + '…'
130+
131+
return () => {
132+
el.textContent = prevText
133+
}
134+
}
135+
84136
/* ---------------- helpers: idénticos a tu snippet ---------------- */
85137

86-
function getClamp(el) {
87-
const cs = getComputedStyle(el)
138+
function getClamp(cs) {
88139
let v = cs.getPropertyValue('-webkit-line-clamp') || cs.getPropertyValue('line-clamp')
89140
v = (v || '').trim()
90141
const n = parseInt(v, 10)

0 commit comments

Comments
 (0)