@viliha/vui-ui 1.14.1 → 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 +2 -0
- package/CHANGELOG.md +35 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/record-view.tsx +82 -15
- package/src/tooltip.tsx +98 -0
- package/template/.env.example +6 -0
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,41 @@ 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
|
+
|
|
29
|
+
## 1.14.2 — 2026-07-26
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- **Consistent loading feedback in `fetcher` mode.** A cache hit is served from
|
|
34
|
+
the in-memory cache (no server round-trip), but the loading shimmer now stays
|
|
35
|
+
up for a short minimum (300ms) so a cached load looks the same as a real
|
|
36
|
+
fetch — no confusing "loading but blank" flash on tab switch. Real fetches
|
|
37
|
+
longer than the minimum are unaffected; background (post-mutation) refetches
|
|
38
|
+
stay silent.
|
|
39
|
+
|
|
40
|
+
Note: the `fetcher` cache (added in 1.14.0) stores each page's data in a
|
|
41
|
+
module-scoped `Map` in JS memory, so switching tabs serves from memory and does
|
|
42
|
+
**not** re-hit the server. If you still see a refetch, you're on a build older
|
|
43
|
+
than 1.14.0 (the demo before that had no cache).
|
|
44
|
+
|
|
10
45
|
## 1.14.1 — 2026-07-26
|
|
11
46
|
|
|
12
47
|
### Fixed
|
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.
|
|
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",
|
package/src/record-view.tsx
CHANGED
|
@@ -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.
|
|
@@ -359,6 +363,31 @@ function resolveOptions<T>(
|
|
|
359
363
|
type RvCacheEntry = { rows: unknown[]; total: number; at: number };
|
|
360
364
|
const RV_CACHE = new Map<string, Map<string, RvCacheEntry>>();
|
|
361
365
|
|
|
366
|
+
// Minimum time the loading shimmer stays up per `fetcher` load, so a cache hit
|
|
367
|
+
// (instant, from memory) shows the same animation as a real fetch — consistent
|
|
368
|
+
// feedback instead of a blank flash. Real fetches longer than this are unaffected.
|
|
369
|
+
const RV_MIN_LOADING_MS = 300;
|
|
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
|
+
|
|
362
391
|
function rvQueryKey<T>(q: ServerQuery<T>): string {
|
|
363
392
|
return JSON.stringify([q.page, q.pageSize, q.sort, q.search, q.filters]);
|
|
364
393
|
}
|
|
@@ -474,6 +503,10 @@ interface RecordViewProps<T extends { id: RowId }> {
|
|
|
474
503
|
/** Called when a `fetcher` request rejects (non-abort). RecordView keeps the
|
|
475
504
|
* previously loaded data and clears the loading state. */
|
|
476
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;
|
|
477
510
|
}
|
|
478
511
|
|
|
479
512
|
export function RecordView<T extends { id: RowId }>({
|
|
@@ -504,6 +537,7 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
504
537
|
cacheKey,
|
|
505
538
|
cache,
|
|
506
539
|
onError,
|
|
540
|
+
maxCellChars = MAX_CELL_CHARS,
|
|
507
541
|
}: RecordViewProps<T>) {
|
|
508
542
|
const { titleLeading } = React.useContext(PageChromeContext);
|
|
509
543
|
// Surface the page title/icon in the app's global top bar.
|
|
@@ -528,17 +562,32 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
528
562
|
const runFetch = React.useCallback(
|
|
529
563
|
(q: ServerQuery<T>, opts?: { background?: boolean }) => {
|
|
530
564
|
if (!fetcher) return;
|
|
531
|
-
|
|
565
|
+
const id = ++reqIdRef.current;
|
|
566
|
+
const started = Date.now();
|
|
567
|
+
// Reveal the data, but hold the shimmer for a consistent minimum so a
|
|
568
|
+
// cache hit (served from memory, no server call) looks the same as a real
|
|
569
|
+
// fetch — same animation every time, never a confusing blank flash.
|
|
570
|
+
const commit = (rows: T[], total: number) => {
|
|
571
|
+
const apply = () => {
|
|
572
|
+
if (id !== reqIdRef.current) return; // superseded
|
|
573
|
+
setFetchedData(rows);
|
|
574
|
+
setFetchedTotal(total);
|
|
575
|
+
setFetchedLoading(false);
|
|
576
|
+
};
|
|
577
|
+
const wait = RV_MIN_LOADING_MS - (Date.now() - started);
|
|
578
|
+
if (opts?.background || wait <= 0) apply();
|
|
579
|
+
else window.setTimeout(apply, wait);
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
// Foreground cache hit: no server round-trip — the data is in memory.
|
|
532
583
|
if (!opts?.background && cacheKey) {
|
|
533
584
|
const hit = rvCacheGet(cacheKey, rvQueryKey(q), ttlMs);
|
|
534
585
|
if (hit) {
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
setFetchedLoading(false);
|
|
586
|
+
setFetchedLoading(true);
|
|
587
|
+
commit(hit.rows as T[], hit.total);
|
|
538
588
|
return;
|
|
539
589
|
}
|
|
540
590
|
}
|
|
541
|
-
const id = ++reqIdRef.current;
|
|
542
591
|
abortRef.current?.abort();
|
|
543
592
|
const controller = new AbortController();
|
|
544
593
|
abortRef.current = controller;
|
|
@@ -553,9 +602,7 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
553
602
|
{ rows: res.rows, total: res.total, at: Date.now() },
|
|
554
603
|
cacheMax,
|
|
555
604
|
);
|
|
556
|
-
|
|
557
|
-
setFetchedTotal(res.total);
|
|
558
|
-
setFetchedLoading(false);
|
|
605
|
+
commit(res.rows, res.total);
|
|
559
606
|
})
|
|
560
607
|
.catch((err) => {
|
|
561
608
|
if (controller.signal.aborted || id !== reqIdRef.current) return;
|
|
@@ -1123,6 +1170,7 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
1123
1170
|
);
|
|
1124
1171
|
}
|
|
1125
1172
|
const value = String(row[field.key] ?? "");
|
|
1173
|
+
const clip = clipCell(value, field.maxChars ?? maxCellChars);
|
|
1126
1174
|
const cellKey = `${row.id}:${field.key}`;
|
|
1127
1175
|
const hoverActions =
|
|
1128
1176
|
(field.editable || (field.copyable && value)) ? (
|
|
@@ -1173,9 +1221,15 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
1173
1221
|
ALIGN_BOX[alignOf(field.key)],
|
|
1174
1222
|
)}
|
|
1175
1223
|
>
|
|
1176
|
-
|
|
1177
|
-
{
|
|
1178
|
-
|
|
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
|
+
)}
|
|
1179
1233
|
</button>
|
|
1180
1234
|
{hoverActions}
|
|
1181
1235
|
</div>
|
|
@@ -1188,7 +1242,13 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
1188
1242
|
ALIGN_BOX[alignOf(field.key)],
|
|
1189
1243
|
)}
|
|
1190
1244
|
>
|
|
1191
|
-
|
|
1245
|
+
{clip.full ? (
|
|
1246
|
+
<Tooltip content={clip.full} className="truncate">
|
|
1247
|
+
{clip.text}
|
|
1248
|
+
</Tooltip>
|
|
1249
|
+
) : (
|
|
1250
|
+
<span className="truncate">{clip.text}</span>
|
|
1251
|
+
)}
|
|
1192
1252
|
{hoverActions}
|
|
1193
1253
|
</div>
|
|
1194
1254
|
);
|
|
@@ -1731,6 +1791,7 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
1731
1791
|
) : processed.length ? (
|
|
1732
1792
|
paged.map((row) => {
|
|
1733
1793
|
const primary = getPrimary(row);
|
|
1794
|
+
const nameClip = clipCell(primary.title, maxCellChars);
|
|
1734
1795
|
return (
|
|
1735
1796
|
<TableRow
|
|
1736
1797
|
key={row.id}
|
|
@@ -1798,9 +1859,15 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
1798
1859
|
<span className="flex size-5 shrink-0 items-center justify-center rounded bg-muted font-medium text-muted-foreground">
|
|
1799
1860
|
{primary.initials}
|
|
1800
1861
|
</span>
|
|
1801
|
-
|
|
1802
|
-
{
|
|
1803
|
-
|
|
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
|
+
)}
|
|
1804
1871
|
</button>
|
|
1805
1872
|
</TableCell>
|
|
1806
1873
|
{visibleFields.map((f) => (
|
package/src/tooltip.tsx
ADDED
|
@@ -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
|
+
}
|
package/template/.env.example
CHANGED
|
@@ -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
|