@viliha/vui-ui 1.14.2 → 1.16.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 +4 -0
- package/CHANGELOG.md +40 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/record-view.tsx +76 -12
- package/src/tooltip.tsx +98 -0
- package/template/.env.example +6 -0
package/AGENT.md
CHANGED
|
@@ -346,6 +346,10 @@ 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
|
+
|
|
351
|
+
A choice field (`options`) shows the option's **label** in the cell (e.g. `SYSTEM` → "System") while staying editable — don't use `render` just to map an enum to a friendly label. To customize the full-page form's breadcrumb, pass a `crumbs` array to `RecordForm` (each `{ label, onClick? }`, last = current page) — e.g. to add an "Access" parent or rename "Create new role" → "New Role".
|
|
352
|
+
|
|
349
353
|
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
354
|
|
|
351
355
|
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,46 @@ 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.16.0 — 2026-07-27
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Configurable page-form breadcrumb.** `RecordForm` (page layout) takes a
|
|
15
|
+
`crumbs` prop that fully replaces the default `Home › {title} › Create/Update
|
|
16
|
+
{singular}` trail — add parents (e.g. an "Access" section), rename the last
|
|
17
|
+
crumb ("New Role"), or reshape it per route. Each crumb is `{ label, onClick? }`;
|
|
18
|
+
the last is the current page. The `Crumb` type is re-exported from
|
|
19
|
+
`@viliha/vui-ui/record-view` (also in `@viliha/vui-ui/breadcrumbs`).
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- **Choice-field cells show the option label, not the raw value.** A field with
|
|
24
|
+
`options` now renders its matching label in the table (e.g. `SYSTEM` →
|
|
25
|
+
"System") while staying editable — no need for a read-only `render` just to map
|
|
26
|
+
an enum to a friendly label. Falls back to the raw value when unmatched.
|
|
27
|
+
|
|
28
|
+
**For agents:** use `options` labels for enum columns (not `render`), and pass
|
|
29
|
+
`crumbs` to `RecordForm` to shape a route's breadcrumb. Docs: `/docs/data-table`.
|
|
30
|
+
|
|
31
|
+
## 1.15.0 — 2026-07-27
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- **Cell truncation in `RecordView` — long text stays on one line.** A cell
|
|
36
|
+
longer than `maxCellChars` is clipped with an ellipsis (…) and shows the full
|
|
37
|
+
value in a **styled hover tooltip**, so a sentence never wraps to a second row.
|
|
38
|
+
The limit defaults to `NEXT_PUBLIC_MAX_CELL_CHARS` (or 25) and is overridable
|
|
39
|
+
per view (`maxCellChars` prop) or per column (a field's `maxChars`; `0` = never
|
|
40
|
+
truncate). Applies to the identity/Name cell and every value column.
|
|
41
|
+
- **`Tooltip`** (`@viliha/vui-ui/tooltip`) — a lightweight, dependency-free themed
|
|
42
|
+
tooltip (portal + fixed positioning, hover/focus, auto-flip). Used by the cell
|
|
43
|
+
truncation above; reusable anywhere.
|
|
44
|
+
|
|
45
|
+
**For agents:** rely on cell truncation instead of adding `truncate`/`title`
|
|
46
|
+
to cells yourself; set `maxChars` on a field to widen/disable a specific column.
|
|
47
|
+
For your own tooltips use `Tooltip`, not native `title`. Env:
|
|
48
|
+
`NEXT_PUBLIC_MAX_CELL_CHARS`. Docs: `/docs/data-table`, `/docs/configuration`.
|
|
49
|
+
|
|
10
50
|
## 1.14.2 — 2026-07-26
|
|
11
51
|
|
|
12
52
|
### 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.
|
|
3
|
+
"version": "1.16.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
|
@@ -34,11 +34,13 @@ import {
|
|
|
34
34
|
|
|
35
35
|
import { cn } from "./utils";
|
|
36
36
|
import { Breadcrumbs, type Crumb } from "./breadcrumbs";
|
|
37
|
+
export type { Crumb } from "./breadcrumbs";
|
|
37
38
|
import { Button } from "./button";
|
|
38
39
|
import { Checkbox } from "./checkbox";
|
|
39
40
|
import { Input } from "./input";
|
|
40
41
|
import { Select } from "./select";
|
|
41
42
|
import { Combobox } from "./combobox";
|
|
43
|
+
import { Tooltip } from "./tooltip";
|
|
42
44
|
import {
|
|
43
45
|
Table,
|
|
44
46
|
TableBody,
|
|
@@ -245,6 +247,9 @@ export interface RecordField<T> {
|
|
|
245
247
|
width?: number;
|
|
246
248
|
/** Show a copy-to-clipboard action on hover (e.g. email, phone). */
|
|
247
249
|
copyable?: boolean;
|
|
250
|
+
/** Max characters this cell shows before truncating with an ellipsis + hover
|
|
251
|
+
* tooltip. Overrides the view's `maxCellChars`. Set `0` to never truncate. */
|
|
252
|
+
maxChars?: number;
|
|
248
253
|
/** Show in the detail panel only, not as a table column (e.g. first/last name). */
|
|
249
254
|
hideInTable?: boolean;
|
|
250
255
|
/** Whether this field can be sorted — decoupled from column visibility.
|
|
@@ -364,6 +369,26 @@ const RV_CACHE = new Map<string, Map<string, RvCacheEntry>>();
|
|
|
364
369
|
// feedback instead of a blank flash. Real fetches longer than this are unaffected.
|
|
365
370
|
const RV_MIN_LOADING_MS = 300;
|
|
366
371
|
|
|
372
|
+
// `process.env.NEXT_PUBLIC_*` is statically inlined by the consumer's bundler
|
|
373
|
+
// (Next / Vite) at build; declare its shape so this source type-checks on its
|
|
374
|
+
// own (the package ships without @types/node).
|
|
375
|
+
declare const process: { env: Record<string, string | undefined> };
|
|
376
|
+
|
|
377
|
+
// Default max characters a table cell shows before truncating with an ellipsis
|
|
378
|
+
// (+ hover tooltip). From env (inlined at build), fallback 25. Override per-view
|
|
379
|
+
// with `maxCellChars`, or per-field with `maxChars` (0 = never truncate).
|
|
380
|
+
const MAX_CELL_CHARS = (() => {
|
|
381
|
+
const n = Number(process.env.NEXT_PUBLIC_MAX_CELL_CHARS);
|
|
382
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 25;
|
|
383
|
+
})();
|
|
384
|
+
|
|
385
|
+
/** Clip a cell string to `max` characters. Returns the display text and, when
|
|
386
|
+
* clipped, the full text for a `title` (hover tooltip). */
|
|
387
|
+
function clipCell(value: string, max: number): { text: string; full?: string } {
|
|
388
|
+
if (max <= 0 || value.length <= max) return { text: value };
|
|
389
|
+
return { text: value.slice(0, max).trimEnd() + "…", full: value };
|
|
390
|
+
}
|
|
391
|
+
|
|
367
392
|
function rvQueryKey<T>(q: ServerQuery<T>): string {
|
|
368
393
|
return JSON.stringify([q.page, q.pageSize, q.sort, q.search, q.filters]);
|
|
369
394
|
}
|
|
@@ -479,6 +504,10 @@ interface RecordViewProps<T extends { id: RowId }> {
|
|
|
479
504
|
/** Called when a `fetcher` request rejects (non-abort). RecordView keeps the
|
|
480
505
|
* previously loaded data and clears the loading state. */
|
|
481
506
|
onError?: (error: unknown, query: ServerQuery<T>) => void;
|
|
507
|
+
/** Max characters any table cell shows before truncating to one line with an
|
|
508
|
+
* ellipsis + hover tooltip (long text never wraps). Defaults to
|
|
509
|
+
* `NEXT_PUBLIC_MAX_CELL_CHARS` (or 25). Per-field `maxChars` overrides it. */
|
|
510
|
+
maxCellChars?: number;
|
|
482
511
|
}
|
|
483
512
|
|
|
484
513
|
export function RecordView<T extends { id: RowId }>({
|
|
@@ -509,6 +538,7 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
509
538
|
cacheKey,
|
|
510
539
|
cache,
|
|
511
540
|
onError,
|
|
541
|
+
maxCellChars = MAX_CELL_CHARS,
|
|
512
542
|
}: RecordViewProps<T>) {
|
|
513
543
|
const { titleLeading } = React.useContext(PageChromeContext);
|
|
514
544
|
// Surface the page title/icon in the app's global top bar.
|
|
@@ -1141,6 +1171,12 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
1141
1171
|
);
|
|
1142
1172
|
}
|
|
1143
1173
|
const value = String(row[field.key] ?? "");
|
|
1174
|
+
// For a choice field, show the option's friendly label (e.g. SYSTEM →
|
|
1175
|
+
// "System") while the cell stays editable — no `render`, no read-only.
|
|
1176
|
+
const display = Array.isArray(field.options)
|
|
1177
|
+
? (field.options.find((o) => o.value === value)?.label ?? value)
|
|
1178
|
+
: value;
|
|
1179
|
+
const clip = clipCell(display, field.maxChars ?? maxCellChars);
|
|
1144
1180
|
const cellKey = `${row.id}:${field.key}`;
|
|
1145
1181
|
const hoverActions =
|
|
1146
1182
|
(field.editable || (field.copyable && value)) ? (
|
|
@@ -1191,9 +1227,15 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
1191
1227
|
ALIGN_BOX[alignOf(field.key)],
|
|
1192
1228
|
)}
|
|
1193
1229
|
>
|
|
1194
|
-
|
|
1195
|
-
{
|
|
1196
|
-
|
|
1230
|
+
{clip.full ? (
|
|
1231
|
+
<Tooltip content={clip.full} className="truncate">
|
|
1232
|
+
{clip.text}
|
|
1233
|
+
</Tooltip>
|
|
1234
|
+
) : (
|
|
1235
|
+
<span className="truncate">
|
|
1236
|
+
{clip.text || <span className="text-muted-foreground">—</span>}
|
|
1237
|
+
</span>
|
|
1238
|
+
)}
|
|
1197
1239
|
</button>
|
|
1198
1240
|
{hoverActions}
|
|
1199
1241
|
</div>
|
|
@@ -1206,7 +1248,13 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
1206
1248
|
ALIGN_BOX[alignOf(field.key)],
|
|
1207
1249
|
)}
|
|
1208
1250
|
>
|
|
1209
|
-
|
|
1251
|
+
{clip.full ? (
|
|
1252
|
+
<Tooltip content={clip.full} className="truncate">
|
|
1253
|
+
{clip.text}
|
|
1254
|
+
</Tooltip>
|
|
1255
|
+
) : (
|
|
1256
|
+
<span className="truncate">{clip.text}</span>
|
|
1257
|
+
)}
|
|
1210
1258
|
{hoverActions}
|
|
1211
1259
|
</div>
|
|
1212
1260
|
);
|
|
@@ -1749,6 +1797,7 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
1749
1797
|
) : processed.length ? (
|
|
1750
1798
|
paged.map((row) => {
|
|
1751
1799
|
const primary = getPrimary(row);
|
|
1800
|
+
const nameClip = clipCell(primary.title, maxCellChars);
|
|
1752
1801
|
return (
|
|
1753
1802
|
<TableRow
|
|
1754
1803
|
key={row.id}
|
|
@@ -1816,9 +1865,15 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
1816
1865
|
<span className="flex size-5 shrink-0 items-center justify-center rounded bg-muted font-medium text-muted-foreground">
|
|
1817
1866
|
{primary.initials}
|
|
1818
1867
|
</span>
|
|
1819
|
-
|
|
1820
|
-
{
|
|
1821
|
-
|
|
1868
|
+
{nameClip.full ? (
|
|
1869
|
+
<Tooltip content={nameClip.full} className="truncate">
|
|
1870
|
+
{nameClip.text}
|
|
1871
|
+
</Tooltip>
|
|
1872
|
+
) : (
|
|
1873
|
+
<span className="truncate">
|
|
1874
|
+
{nameClip.text || "—"}
|
|
1875
|
+
</span>
|
|
1876
|
+
)}
|
|
1822
1877
|
</button>
|
|
1823
1878
|
</TableCell>
|
|
1824
1879
|
{visibleFields.map((f) => (
|
|
@@ -2025,6 +2080,11 @@ interface DetailPanelProps<T extends { id: RowId }> {
|
|
|
2025
2080
|
/** Persist the in-progress draft under this key (e.g. the route), so a
|
|
2026
2081
|
* half-filled form survives leaving and returning via the open-tabs strip. */
|
|
2027
2082
|
persistKey?: string;
|
|
2083
|
+
/** Page-form breadcrumb override (fully configurable). When set, these crumbs
|
|
2084
|
+
* replace the default `Home › {title} › Create/Update {singular}` — so you can
|
|
2085
|
+
* add parents ("Access") or rename the last crumb ("New Role"). Build each
|
|
2086
|
+
* crumb as `{ label, onClick? }`; the last one is the current page. */
|
|
2087
|
+
crumbs?: Crumb[];
|
|
2028
2088
|
}
|
|
2029
2089
|
|
|
2030
2090
|
function RecordDetailPanel<T extends { id: RowId }>({
|
|
@@ -2044,6 +2104,7 @@ function RecordDetailPanel<T extends { id: RowId }>({
|
|
|
2044
2104
|
onHome,
|
|
2045
2105
|
formDescription,
|
|
2046
2106
|
persistKey,
|
|
2107
|
+
crumbs,
|
|
2047
2108
|
}: DetailPanelProps<T>) {
|
|
2048
2109
|
const draftKey = persistKey ? `${persistKey}::draft` : undefined;
|
|
2049
2110
|
const [draft, setDraft] = usePersistentState<T>(draftKey, row);
|
|
@@ -2315,11 +2376,14 @@ function RecordDetailPanel<T extends { id: RowId }>({
|
|
|
2315
2376
|
<div className="flex h-12 shrink-0 items-center border-b border-border px-4">
|
|
2316
2377
|
<Breadcrumbs
|
|
2317
2378
|
onBack={onCancel}
|
|
2318
|
-
crumbs={
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2379
|
+
crumbs={
|
|
2380
|
+
crumbs ??
|
|
2381
|
+
([
|
|
2382
|
+
...(onHome ? [{ label: "Home", onClick: onHome }] : []),
|
|
2383
|
+
{ label: title ?? singular, onClick: onCancel },
|
|
2384
|
+
{ label: crumb },
|
|
2385
|
+
] as Crumb[])
|
|
2386
|
+
}
|
|
2323
2387
|
/>
|
|
2324
2388
|
</div>
|
|
2325
2389
|
{/* Content — form card (left) + optional documentation panel (right). */}
|
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
|