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 (
-
+
- {label}
+ {label}
);
}
@@ -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"