From 8c376e7ab1eea4a4ea3946bca5b7f57bf5e7c079 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 07:47:07 +0000 Subject: [PATCH] =?UTF-8?q?fix(fields):=20=E8=AE=A9=E5=8D=95=E8=A1=8C?= =?UTF-8?q?=E5=80=BC=E6=B8=B2=E6=9F=93=E5=99=A8=E7=9A=84=20truncate=20?= =?UTF-8?q?=E7=9C=9F=E6=AD=A3=E7=94=9F=E6=95=88=E5=B9=B6=E7=94=A8=20title?= =?UTF-8?q?=20=E5=85=9C=E5=BA=95=E5=85=A8=E6=96=87=20(#3466)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 裸行内 span.truncate 没有宽度框,overflow:hidden / text-overflow:ellipsis 永不触发 —— 详情页字段值(lookup 尤甚)超长时尾部被外层卡片无声裁掉, 既无省略号、悬浮也看不到全文(行容器的 title 被「双击编辑」占用)。 与 JsonCellRenderer(#2578)/ Grid LinkCell 同一根因,现将该修复范式收敛为 共享的 TruncatedText(block + max-w-full + truncate,title 携带全文): Text / Lookup×4 / User×3 / File / Json 各渲染器统一接入;Select 的 dot 分支给外层 inline-flex 补 max-w-full 并加 title;LookupField 触发按钮补 max-w-full,修掉内联编辑态选中标签把按钮撑出卡片右缘的附带问题。 值元素自身携带 title 后,悬浮文本优先显示全文(就近原则盖过行容器的 交互提示),交互提示仍由行 title + 铅笔按钮 tooltip 承载。 Fixes #3466 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019itDQ7HMpXML7UdtFhjnd8 --- .../src/__tests__/cell-truncation.test.tsx | 96 +++++++++++++++++++ packages/fields/src/index.tsx | 69 ++++++++----- packages/fields/src/widgets/LookupField.tsx | 7 +- 3 files changed, 149 insertions(+), 23 deletions(-) create mode 100644 packages/fields/src/__tests__/cell-truncation.test.tsx diff --git a/packages/fields/src/__tests__/cell-truncation.test.tsx b/packages/fields/src/__tests__/cell-truncation.test.tsx new file mode 100644 index 0000000000..f420487cf3 --- /dev/null +++ b/packages/fields/src/__tests__/cell-truncation.test.tsx @@ -0,0 +1,96 @@ +/** + * Regression for objectstack-ai/objectui#3466: single-line cell renderers + * emitted a BARE INLINE `span.truncate`. An inline box has no width box, so + * `overflow:hidden` / `text-overflow:ellipsis` never engage — the value + * rendered at full content width and its tail was silently clipped by + * whatever ancestor happened to clip (the record-detail card edge), with no + * ellipsis and no way to read the full text (the detail row's `title` is the + * inline-edit hint, not the value). Same mechanism as the JSON cell fix in + * objectui#2578. + * + * Pin: every single-line value renderer emits a BLOCK-level, `max-w-full` + * truncating span that carries the full text in `title` (so hovering the + * value shows the full text, taking precedence over any ancestor `title`). + */ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +import { + TextCellRenderer, + LookupCellRenderer, + UserCellRenderer, + FileCellRenderer, + SelectCellRenderer, +} from '../index'; +import { SchemaRendererProvider } from '@object-ui/react'; + +const LONG = + 'A remarkably long referenced-record title that a detail card column can never fit on a single line without an ellipsis'; + +/** The block-level truncation contract every single-line value span must meet. */ +function expectTruncating(el: HTMLElement, fullText: string) { + expect(el).toHaveClass('block', 'max-w-full', 'truncate'); + expect(el).toHaveAttribute('title', fullText); +} + +describe('cell renderers truncate for real and expose the full text (issue #3466)', () => { + it('TextCellRenderer: block-level truncating span with title fallback', () => { + render(); + expectTruncating(screen.getByText(LONG), LONG); + }); + + it('LookupCellRenderer: expanded record object', () => { + const ds = { find: vi.fn(), findOne: vi.fn() } as any; + render( + + + , + ); + expectTruncating(screen.getByText(LONG), LONG); + }); + + it('LookupCellRenderer: primitive non-opaque value', () => { + const ds = { find: vi.fn(), findOne: vi.fn() } as any; + render( + + + , + ); + expectTruncating(screen.getByText(LONG), LONG); + }); + + it('UserCellRenderer: display name beside the avatar', () => { + render(); + expectTruncating(screen.getByText(LONG), LONG); + }); + + it('FileCellRenderer: single file name', () => { + render(); + expectTruncating(screen.getByText(LONG), LONG); + }); + + it('SelectCellRenderer (dot): bounded container with title, shrinkable label', () => { + render( + , + ); + const label = screen.getByText(LONG); + // The label shrinks inside the dot row; the row itself is width-bounded + // and carries the full text on hover. + expect(label).toHaveClass('min-w-0', 'truncate'); + const row = label.parentElement!; + expect(row).toHaveClass('max-w-full'); + expect(row).toHaveAttribute('title', LONG); + }); +}); diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 1fd39ad6d1..df0f44c1f5 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -590,13 +590,42 @@ export function formatDateTime(value: string | Date | number): string { }); } +/** + * Single-line cell value with a working ellipsis and a full-text fallback. + * + * `truncate` on a bare inline `` never clips — an inline box has no + * width box for `overflow:hidden` / `text-overflow:ellipsis` to act on, so + * the value renders at full content width and the tail is silently cut by + * whatever ancestor happens to clip (the detail card edge), with no ellipsis + * (objectui#3466; same mechanism as the JSON cell in objectui#2578). + * Block-level + `max-w-full` gives the span its parent's width to truncate + * against, and as a flex item its `overflow:hidden` zeroes the automatic + * min-size so it can shrink below the text width. The `title` keeps the full + * text reachable on hover — host rows (detail sections) put their inline-edit + * hint in *their* `title`, so the value's full text must live on the value + * element itself, where it takes precedence under the cursor. + */ +function TruncatedText({ + text, + className, +}: { + text: string; + className?: string; +}): React.ReactElement { + return ( + + {text} + + ); +} + /** * Text field cell renderer */ export function TextCellRenderer({ value }: CellRendererProps): React.ReactElement { const safe = coerceToSafeValue(value); if (safe == null || safe === '') return ; - return {String(safe)}; + return ; } /** @@ -1090,10 +1119,13 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R || SEMANTIC_COLOR_MAP[String(val).toLowerCase().replace(/[\s-]/g, '_')] || hashToColor(String(val).toLowerCase().replace(/[\s-]/g, '_')); const dotClass = DOT_COLOR_MAP[colorName] || DOT_COLOR_MAP.gray; + // max-w-full bounds the (otherwise content-sized) inline-flex box so the + // inner truncate can engage; title keeps the full label on hover + // (objectui#3466, same class of bug as the badge branch below). return ( - + ); } @@ -1267,7 +1299,7 @@ export function FileCellRenderer({ value, field }: CellRendererProps): React.Rea } const fileName = value.name || value.original_name || 'File'; - return {fileName}; + return ; } /** @@ -1436,7 +1468,7 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R String(parsed.externalId ?? parsed.id ?? parsed._id ?? ''); } } catch { /* not JSON — fall through to normal resolution */ } - if (parsedDisplay) return {parsedDisplay}; + if (parsedDisplay) return ; } } @@ -1447,7 +1479,7 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R const display = resolveLookupRecordName(obj, refSchema, displayField) || String(obj.id || obj._id || ''); if (display) { - return {display}; + return ; } } @@ -1507,12 +1539,12 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R const label = resolveLookupRecordName(value as Record, refSchema, displayField) || String((value as any).id || (value as any)._id || '[Object]'); - return {label}; + return ; } // Primitive value (e.g. raw ID): try options → resolver → opaque-ID placeholder → raw const { text, muted } = resolveLabel(value); - return {text}; + return ; } /** @@ -1536,7 +1568,7 @@ export function UserCellRenderer({ value }: CellRendererProps): React.ReactEleme // Primitive value: just display the ID/username as text if (typeof value !== 'object') { - return {String(value)}; + return ; } if (Array.isArray(value)) { @@ -1545,11 +1577,7 @@ export function UserCellRenderer({ value }: CellRendererProps): React.ReactEleme {value.slice(0, 3).map((user, idx) => { // Primitive user in array if (typeof user !== 'object' || user === null) { - return ( - - {String(user)} - - ); + return ; } const name = user.name || user.username || 'User'; const initials = name.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2); @@ -1589,7 +1617,7 @@ export function UserCellRenderer({ value }: CellRendererProps): React.ReactEleme {initials} - {name} + ); } @@ -1671,13 +1699,10 @@ export function JsonCellRenderer({ value }: CellRendererProps): React.ReactEleme } else { text = String(value); } - // inline-block + max-w-full so `truncate` (overflow-hidden/ellipsis/nowrap) - // actually clamps to the cell width. On a bare inline truncate never - // clips — there is no width box — and its `white-space:nowrap` also defeats - // the parent cell's `break-words`, so a long name-keyed map / address JSON - // spills into the neighbouring column (objectui#2578). The title keeps the - // full value on hover. - return {text}; + // The original site of the block-level+max-w-full+title pattern + // (objectui#2578) — now shared with every single-line value renderer via + // TruncatedText (objectui#3466). + return ; } /** diff --git a/packages/fields/src/widgets/LookupField.tsx b/packages/fields/src/widgets/LookupField.tsx index 1deadce319..79d4494611 100644 --- a/packages/fields/src/widgets/LookupField.tsx +++ b/packages/fields/src/widgets/LookupField.tsx @@ -941,7 +941,12 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro {...triggerDomProps} variant="outline" className={cn( - 'min-w-0 flex-1 justify-start text-left font-normal', + // max-w-full: in a BLOCK parent (detail-section inline edit) the + // inline-flex button is content-sized — a long selected label pushed + // it past the card edge; bounding it lets the inner `truncate` span + // clip instead (objectui#3466). flex-1/min-w-0 keep handling the + // flex-parent (form row) case. + 'min-w-0 max-w-full flex-1 justify-start text-left font-normal', compact && 'h-8 rounded-none border-0 bg-transparent px-2 shadow-none focus-visible:ring-1 focus-visible:ring-ring/60', )} type="button"