@viliha/vui-ui 1.14.2 → 1.15.0

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.
package/AGENT.md CHANGED
@@ -346,6 +346,8 @@ Build forms with VUI and shadcn/ui together, reaching for shadcn Form, React Hoo
346
346
 
347
347
  Never hand-build an HTML table; always use `RecordView`. Configure columns through its field props (`editable`, `required`, `copyable`, `options`, `render`), and let `RecordView` own the rest: sorting, filtering, pagination, bulk actions, and import/export.
348
348
 
349
+ Long cell text never wraps: cells truncate to one line at `maxCellChars` (view prop; defaults to `NEXT_PUBLIC_MAX_CELL_CHARS` or 25) with an ellipsis + hover tooltip. Override a column with the field's `maxChars` (`0` = never truncate). Don't add your own `truncate`/`title` on cells — it's built in.
350
+
349
351
  Sorting follows column visibility by default; use `sortable` to decouple them — `sortable: true` sorts a field with no column (e.g. a `hideInTable` name shown via `getPrimary`), `sortable: false` locks a visible column. Set the flag; don't rewire the Sort dropdown or headers.
350
352
 
351
353
  Form controls come from the field: `options` → `Select`, `input: "combobox"` → a searchable `Combobox` (`@viliha/vui-ui/combobox`, use for long lists), `input: "number" | "date"` → native inputs. For anything the built-ins don't cover (checkbox, radio group, slider, custom widget), set `renderInput({ value, onChange, field, invalid })` on the field — it drops any component into the Add/Edit form while the field keeps its label, required mark, and Save validation. Don't hand-build a `<form>`.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,25 @@ backward-compatible features, **major** for breaking changes.
7
7
 
8
8
  To upgrade, see [Upgrading](./AGENT.md#upgrading) in the agent guide.
9
9
 
10
+ ## 1.15.0 — 2026-07-27
11
+
12
+ ### Added
13
+
14
+ - **Cell truncation in `RecordView` — long text stays on one line.** A cell
15
+ longer than `maxCellChars` is clipped with an ellipsis (…) and shows the full
16
+ value in a **styled hover tooltip**, so a sentence never wraps to a second row.
17
+ The limit defaults to `NEXT_PUBLIC_MAX_CELL_CHARS` (or 25) and is overridable
18
+ per view (`maxCellChars` prop) or per column (a field's `maxChars`; `0` = never
19
+ truncate). Applies to the identity/Name cell and every value column.
20
+ - **`Tooltip`** (`@viliha/vui-ui/tooltip`) — a lightweight, dependency-free themed
21
+ tooltip (portal + fixed positioning, hover/focus, auto-flip). Used by the cell
22
+ truncation above; reusable anywhere.
23
+
24
+ **For agents:** rely on cell truncation instead of adding `truncate`/`title`
25
+ to cells yourself; set `maxChars` on a field to widen/disable a specific column.
26
+ For your own tooltips use `Tooltip`, not native `title`. Env:
27
+ `NEXT_PUBLIC_MAX_CELL_CHARS`. Docs: `/docs/data-table`, `/docs/configuration`.
28
+
10
29
  ## 1.14.2 — 2026-07-26
11
30
 
12
31
  ### Changed
package/README.md CHANGED
@@ -252,7 +252,7 @@ place. If you'd rather keep a verbatim copy, run
252
252
  wrapper) · `checkbox` · `combobox` (searchable single-select) · `command-palette`
253
253
  (⌘K launcher) · `dialog` · `confirm-dialog` · `dropdown-menu` · `input` · `kbd`
254
254
  (key caps + `Shortcut`) · `menu` · `required-mark` · `select` · `steps`
255
- (multi-step wizard indicator) ·
255
+ (multi-step wizard indicator) · `tooltip` ·
256
256
  `table` · `record-view` (the full datatable + `RecordForm`) · plus the `utils`
257
257
  (`cn`) helper and the `theme.css` design tokens.
258
258
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viliha/vui-ui",
3
- "version": "1.14.2",
3
+ "version": "1.15.0",
4
4
  "description": "Vui UI — a clean, token-driven React admin/CRM component library built on Tailwind CSS v4, shadcn-style patterns, and Radix Icons. Ships as TypeScript source (Just-in-Time), compiled by the consuming app.",
5
5
  "license": "MIT",
6
6
  "author": "Suman Bonakurthi",
@@ -39,6 +39,7 @@ import { Checkbox } from "./checkbox";
39
39
  import { Input } from "./input";
40
40
  import { Select } from "./select";
41
41
  import { Combobox } from "./combobox";
42
+ import { Tooltip } from "./tooltip";
42
43
  import {
43
44
  Table,
44
45
  TableBody,
@@ -245,6 +246,9 @@ export interface RecordField<T> {
245
246
  width?: number;
246
247
  /** Show a copy-to-clipboard action on hover (e.g. email, phone). */
247
248
  copyable?: boolean;
249
+ /** Max characters this cell shows before truncating with an ellipsis + hover
250
+ * tooltip. Overrides the view's `maxCellChars`. Set `0` to never truncate. */
251
+ maxChars?: number;
248
252
  /** Show in the detail panel only, not as a table column (e.g. first/last name). */
249
253
  hideInTable?: boolean;
250
254
  /** Whether this field can be sorted — decoupled from column visibility.
@@ -364,6 +368,26 @@ const RV_CACHE = new Map<string, Map<string, RvCacheEntry>>();
364
368
  // feedback instead of a blank flash. Real fetches longer than this are unaffected.
365
369
  const RV_MIN_LOADING_MS = 300;
366
370
 
371
+ // `process.env.NEXT_PUBLIC_*` is statically inlined by the consumer's bundler
372
+ // (Next / Vite) at build; declare its shape so this source type-checks on its
373
+ // own (the package ships without @types/node).
374
+ declare const process: { env: Record<string, string | undefined> };
375
+
376
+ // Default max characters a table cell shows before truncating with an ellipsis
377
+ // (+ hover tooltip). From env (inlined at build), fallback 25. Override per-view
378
+ // with `maxCellChars`, or per-field with `maxChars` (0 = never truncate).
379
+ const MAX_CELL_CHARS = (() => {
380
+ const n = Number(process.env.NEXT_PUBLIC_MAX_CELL_CHARS);
381
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 25;
382
+ })();
383
+
384
+ /** Clip a cell string to `max` characters. Returns the display text and, when
385
+ * clipped, the full text for a `title` (hover tooltip). */
386
+ function clipCell(value: string, max: number): { text: string; full?: string } {
387
+ if (max <= 0 || value.length <= max) return { text: value };
388
+ return { text: value.slice(0, max).trimEnd() + "…", full: value };
389
+ }
390
+
367
391
  function rvQueryKey<T>(q: ServerQuery<T>): string {
368
392
  return JSON.stringify([q.page, q.pageSize, q.sort, q.search, q.filters]);
369
393
  }
@@ -479,6 +503,10 @@ interface RecordViewProps<T extends { id: RowId }> {
479
503
  /** Called when a `fetcher` request rejects (non-abort). RecordView keeps the
480
504
  * previously loaded data and clears the loading state. */
481
505
  onError?: (error: unknown, query: ServerQuery<T>) => void;
506
+ /** Max characters any table cell shows before truncating to one line with an
507
+ * ellipsis + hover tooltip (long text never wraps). Defaults to
508
+ * `NEXT_PUBLIC_MAX_CELL_CHARS` (or 25). Per-field `maxChars` overrides it. */
509
+ maxCellChars?: number;
482
510
  }
483
511
 
484
512
  export function RecordView<T extends { id: RowId }>({
@@ -509,6 +537,7 @@ export function RecordView<T extends { id: RowId }>({
509
537
  cacheKey,
510
538
  cache,
511
539
  onError,
540
+ maxCellChars = MAX_CELL_CHARS,
512
541
  }: RecordViewProps<T>) {
513
542
  const { titleLeading } = React.useContext(PageChromeContext);
514
543
  // Surface the page title/icon in the app's global top bar.
@@ -1141,6 +1170,7 @@ export function RecordView<T extends { id: RowId }>({
1141
1170
  );
1142
1171
  }
1143
1172
  const value = String(row[field.key] ?? "");
1173
+ const clip = clipCell(value, field.maxChars ?? maxCellChars);
1144
1174
  const cellKey = `${row.id}:${field.key}`;
1145
1175
  const hoverActions =
1146
1176
  (field.editable || (field.copyable && value)) ? (
@@ -1191,9 +1221,15 @@ export function RecordView<T extends { id: RowId }>({
1191
1221
  ALIGN_BOX[alignOf(field.key)],
1192
1222
  )}
1193
1223
  >
1194
- <span className="truncate">
1195
- {value || <span className="text-muted-foreground">—</span>}
1196
- </span>
1224
+ {clip.full ? (
1225
+ <Tooltip content={clip.full} className="truncate">
1226
+ {clip.text}
1227
+ </Tooltip>
1228
+ ) : (
1229
+ <span className="truncate">
1230
+ {clip.text || <span className="text-muted-foreground">—</span>}
1231
+ </span>
1232
+ )}
1197
1233
  </button>
1198
1234
  {hoverActions}
1199
1235
  </div>
@@ -1206,7 +1242,13 @@ export function RecordView<T extends { id: RowId }>({
1206
1242
  ALIGN_BOX[alignOf(field.key)],
1207
1243
  )}
1208
1244
  >
1209
- <span className="truncate">{value}</span>
1245
+ {clip.full ? (
1246
+ <Tooltip content={clip.full} className="truncate">
1247
+ {clip.text}
1248
+ </Tooltip>
1249
+ ) : (
1250
+ <span className="truncate">{clip.text}</span>
1251
+ )}
1210
1252
  {hoverActions}
1211
1253
  </div>
1212
1254
  );
@@ -1749,6 +1791,7 @@ export function RecordView<T extends { id: RowId }>({
1749
1791
  ) : processed.length ? (
1750
1792
  paged.map((row) => {
1751
1793
  const primary = getPrimary(row);
1794
+ const nameClip = clipCell(primary.title, maxCellChars);
1752
1795
  return (
1753
1796
  <TableRow
1754
1797
  key={row.id}
@@ -1816,9 +1859,15 @@ export function RecordView<T extends { id: RowId }>({
1816
1859
  <span className="flex size-5 shrink-0 items-center justify-center rounded bg-muted font-medium text-muted-foreground">
1817
1860
  {primary.initials}
1818
1861
  </span>
1819
- <span className="truncate">
1820
- {primary.title || ""}
1821
- </span>
1862
+ {nameClip.full ? (
1863
+ <Tooltip content={nameClip.full} className="truncate">
1864
+ {nameClip.text}
1865
+ </Tooltip>
1866
+ ) : (
1867
+ <span className="truncate">
1868
+ {nameClip.text || "—"}
1869
+ </span>
1870
+ )}
1822
1871
  </button>
1823
1872
  </TableCell>
1824
1873
  {visibleFields.map((f) => (
@@ -0,0 +1,98 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { createPortal } from "react-dom";
5
+
6
+ import { cn } from "./utils";
7
+
8
+ type Placement = { left: number; top: number; up: boolean };
9
+
10
+ /**
11
+ * Lightweight styled tooltip. Wraps a trigger and shows `content` in a themed
12
+ * popover on hover/focus (after a short delay), rendered in a portal with fixed
13
+ * positioning so it floats above any scroll/overflow container. Flips above or
14
+ * below depending on room. No dependency — same portal pattern as `Select`.
15
+ */
16
+ export function Tooltip({
17
+ content,
18
+ children,
19
+ className,
20
+ delay = 350,
21
+ }: {
22
+ content: React.ReactNode;
23
+ /** The trigger — wrapped in an inline element that carries the hover/focus. */
24
+ children: React.ReactNode;
25
+ /** Applied to the inline trigger wrapper (e.g. `truncate` for a table cell). */
26
+ className?: string;
27
+ /** Hover open delay in ms. */
28
+ delay?: number;
29
+ }) {
30
+ const [pos, setPos] = React.useState<Placement | null>(null);
31
+ const ref = React.useRef<HTMLSpanElement>(null);
32
+ const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
33
+
34
+ const place = React.useCallback(() => {
35
+ const el = ref.current;
36
+ if (!el) return;
37
+ const r = el.getBoundingClientRect();
38
+ const up = r.top > 72; // room above? else drop below
39
+ setPos({
40
+ left: r.left + r.width / 2,
41
+ top: up ? r.top - 6 : r.bottom + 6,
42
+ up,
43
+ });
44
+ }, []);
45
+
46
+ const open = React.useCallback(() => {
47
+ clearTimeout(timer.current);
48
+ timer.current = setTimeout(place, delay);
49
+ }, [place, delay]);
50
+
51
+ const close = React.useCallback(() => {
52
+ clearTimeout(timer.current);
53
+ setPos(null);
54
+ }, []);
55
+
56
+ React.useEffect(() => () => clearTimeout(timer.current), []);
57
+ // Close on scroll (position would go stale).
58
+ React.useEffect(() => {
59
+ if (!pos) return;
60
+ window.addEventListener("scroll", close, true);
61
+ return () => window.removeEventListener("scroll", close, true);
62
+ }, [pos, close]);
63
+
64
+ return (
65
+ <span
66
+ ref={ref}
67
+ onMouseEnter={open}
68
+ onMouseLeave={close}
69
+ onFocus={open}
70
+ onBlur={close}
71
+ className={className}
72
+ >
73
+ {children}
74
+ {pos &&
75
+ typeof document !== "undefined" &&
76
+ createPortal(
77
+ <div
78
+ role="tooltip"
79
+ style={{
80
+ position: "fixed",
81
+ left: pos.left,
82
+ top: pos.top,
83
+ transform: pos.up
84
+ ? "translate(-50%, -100%)"
85
+ : "translate(-50%, 0)",
86
+ }}
87
+ className={cn(
88
+ "vui-fade-in pointer-events-none z-[220] max-w-xs rounded-md border border-border bg-popover px-2 py-1",
89
+ "text-xs leading-snug text-popover-foreground shadow-md",
90
+ )}
91
+ >
92
+ {content}
93
+ </div>,
94
+ document.body,
95
+ )}
96
+ </span>
97
+ );
98
+ }
@@ -40,6 +40,12 @@ NEXT_PUBLIC_LICENSE="MIT Licensed"
40
40
  # more evicts the oldest tab with a warning.
41
41
  NEXT_PUBLIC_MAX_TABS="5"
42
42
 
43
+ # Max characters a data-table cell shows before truncating to one line with an
44
+ # ellipsis (…) + hover tooltip. Long text never wraps. Default 25. Override per
45
+ # table with the RecordView `maxCellChars` prop, or per column with a field's
46
+ # `maxChars` (0 = never truncate that column).
47
+ NEXT_PUBLIC_MAX_CELL_CHARS="25"
48
+
43
49
  # How a collapsed sidebar rail reveals a group's sub-items. One of:
44
50
  # inline — expands underneath the icon, in the rail itself
45
51
  # flyout-click — click the icon to open a floating panel beside the rail