@viliha/vui-ui 1.2.0 → 1.3.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@viliha/vui-ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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
|
@@ -37,6 +37,7 @@ import { Breadcrumbs, type Crumb } from "./breadcrumbs";
|
|
|
37
37
|
import { Button } from "./button";
|
|
38
38
|
import { Checkbox } from "./checkbox";
|
|
39
39
|
import { Input } from "./input";
|
|
40
|
+
import { Select } from "./select";
|
|
40
41
|
import {
|
|
41
42
|
Table,
|
|
42
43
|
TableBody,
|
|
@@ -191,9 +192,13 @@ export interface RecordField<T> {
|
|
|
191
192
|
hideInTable?: boolean;
|
|
192
193
|
/** Custom, non-editable cell/value renderer. */
|
|
193
194
|
render?: (row: T) => React.ReactNode;
|
|
194
|
-
/** If set, the field becomes a choice field: the
|
|
195
|
-
* "Set {label}" bulk action
|
|
195
|
+
/** If set, the field becomes a choice field: the Add/Edit form renders a
|
|
196
|
+
* `Select`, and the selection toolbar offers a "Set {label}" bulk action. */
|
|
196
197
|
options?: { value: string; label: string }[];
|
|
198
|
+
/** Form control for the Add/Edit panel/page. Default `"text"` (auto-growing
|
|
199
|
+
* textarea). `"number"`/`"date"` render the matching native input; a field
|
|
200
|
+
* with `options` always renders a `Select` regardless of this. */
|
|
201
|
+
input?: "text" | "number" | "date";
|
|
197
202
|
}
|
|
198
203
|
|
|
199
204
|
/**
|
|
@@ -1575,21 +1580,45 @@ function RecordDetailPanel<T extends { id: RowId }>({
|
|
|
1575
1580
|
{f.render ? (
|
|
1576
1581
|
<div>{f.render(draft)}</div>
|
|
1577
1582
|
) : !readOnly && f.editable ? (
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1583
|
+
f.options ? (
|
|
1584
|
+
<Select
|
|
1585
|
+
value={String(draft[f.key as keyof T] ?? "")}
|
|
1586
|
+
onValueChange={(v) => setField(f.key as keyof T, v)}
|
|
1587
|
+
options={f.options}
|
|
1588
|
+
ariaLabel={f.label}
|
|
1589
|
+
placeholder={`Select ${f.label.toLowerCase()}…`}
|
|
1590
|
+
className="w-full"
|
|
1591
|
+
/>
|
|
1592
|
+
) : f.input === "number" || f.input === "date" ? (
|
|
1593
|
+
<Input
|
|
1594
|
+
type={f.input}
|
|
1595
|
+
value={String(draft[f.key as keyof T] ?? "")}
|
|
1596
|
+
onChange={(e) => setField(f.key as keyof T, e.target.value)}
|
|
1597
|
+
aria-label={f.label}
|
|
1598
|
+
aria-invalid={errors.has(f.key) || undefined}
|
|
1599
|
+
className={cn(
|
|
1600
|
+
"w-full",
|
|
1601
|
+
errors.has(f.key) &&
|
|
1602
|
+
"border-destructive focus-visible:ring-destructive",
|
|
1603
|
+
)}
|
|
1604
|
+
/>
|
|
1605
|
+
) : (
|
|
1606
|
+
<textarea
|
|
1607
|
+
value={String(draft[f.key as keyof T] ?? "")}
|
|
1608
|
+
onChange={(e) => setField(f.key as keyof T, e.target.value)}
|
|
1609
|
+
aria-label={f.label}
|
|
1610
|
+
aria-invalid={errors.has(f.key) || undefined}
|
|
1611
|
+
placeholder={`Add ${f.label.toLowerCase()}`}
|
|
1612
|
+
rows={1}
|
|
1613
|
+
// field-sizing grows the box to fit long/wrapped text
|
|
1614
|
+
className={cn(
|
|
1615
|
+
"w-full resize-none rounded-sm border bg-background px-2 py-1.5 outline-none [field-sizing:content] placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-inset",
|
|
1616
|
+
errors.has(f.key)
|
|
1617
|
+
? "border-destructive focus-visible:ring-destructive"
|
|
1618
|
+
: "border-input focus-visible:ring-ring",
|
|
1619
|
+
)}
|
|
1620
|
+
/>
|
|
1621
|
+
)
|
|
1593
1622
|
) : (
|
|
1594
1623
|
<span className="block whitespace-pre-wrap break-words px-2 py-1.5">
|
|
1595
1624
|
{String(draft[f.key as keyof T] ?? "") || (
|
|
@@ -1790,3 +1819,16 @@ export function RecordForm<T extends { id: RowId }>(
|
|
|
1790
1819
|
) {
|
|
1791
1820
|
return <RecordDetailPanel layout="page" {...props} />;
|
|
1792
1821
|
}
|
|
1822
|
+
|
|
1823
|
+
/**
|
|
1824
|
+
* Standalone **slide-over** record form — the standard Add / Edit / View panel
|
|
1825
|
+
* used outside a table (e.g. on a Kanban board). Same overlay, `fields`-driven
|
|
1826
|
+
* layout, blue Save, header/body/footer separators, and auto-width as the
|
|
1827
|
+
* add/edit panel `RecordView` opens. Feed it a `fields` array; never hand-roll
|
|
1828
|
+
* an add/edit form.
|
|
1829
|
+
*/
|
|
1830
|
+
export function RecordFormPanel<T extends { id: RowId }>(
|
|
1831
|
+
props: Omit<DetailPanelProps<T>, "layout">,
|
|
1832
|
+
) {
|
|
1833
|
+
return <RecordDetailPanel layout="panel" {...props} />;
|
|
1834
|
+
}
|
|
@@ -3,8 +3,6 @@
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import {
|
|
5
5
|
CalendarIcon as CalendarDays,
|
|
6
|
-
CheckIcon as Check,
|
|
7
|
-
Cross2Icon as X,
|
|
8
6
|
CubeIcon as Building2,
|
|
9
7
|
DragHandleDots2Icon as GripVertical,
|
|
10
8
|
MagnifyingGlassIcon as Search,
|
|
@@ -20,16 +18,41 @@ import { cn } from "@/lib/utils";
|
|
|
20
18
|
import { Badge } from "@viliha/vui-ui/badge";
|
|
21
19
|
import { Button } from "@viliha/vui-ui/button";
|
|
22
20
|
import { Input } from "@viliha/vui-ui/input";
|
|
21
|
+
import { RecordFormPanel, type RecordField } from "@viliha/vui-ui/record-view";
|
|
23
22
|
import {
|
|
24
23
|
OPPORTUNITY_STAGES,
|
|
25
24
|
opportunities as initialOpportunities,
|
|
26
25
|
type Opportunity,
|
|
27
26
|
type OpportunityStage,
|
|
28
27
|
} from "@/lib/crm-data";
|
|
29
|
-
import { Dropdown, DropdownItem, DropdownLabel } from "@viliha/vui-ui/dropdown-menu";
|
|
30
28
|
import { Breadcrumbs } from "@/app/_components/breadcrumbs";
|
|
31
29
|
import { SetPageTitle } from "@/app/_components/set-page-title";
|
|
32
30
|
|
|
31
|
+
/** The Add/Edit slide-over is designed from this array — never hand-rolled. */
|
|
32
|
+
const OPPORTUNITY_FIELDS: RecordField<Opportunity>[] = [
|
|
33
|
+
{ key: "name", label: "Name", icon: Target, editable: true, required: true },
|
|
34
|
+
{ key: "company", label: "Company", icon: Building2, editable: true },
|
|
35
|
+
{ key: "amount", label: "Amount", icon: Coins, editable: true, input: "number" },
|
|
36
|
+
{
|
|
37
|
+
key: "stage",
|
|
38
|
+
label: "Stage",
|
|
39
|
+
icon: ListFilter,
|
|
40
|
+
editable: true,
|
|
41
|
+
options: OPPORTUNITY_STAGES.map((s) => ({ value: s, label: s })),
|
|
42
|
+
},
|
|
43
|
+
{ key: "owner", label: "Owner", icon: User, editable: true },
|
|
44
|
+
{ key: "closeDate", label: "Close date", icon: CalendarDays, editable: true, input: "date" },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const initials = (name: string) =>
|
|
48
|
+
name
|
|
49
|
+
.trim()
|
|
50
|
+
.split(/\s+/)
|
|
51
|
+
.map((w) => w[0])
|
|
52
|
+
.slice(0, 2)
|
|
53
|
+
.join("")
|
|
54
|
+
.toUpperCase() || "?";
|
|
55
|
+
|
|
33
56
|
function formatCurrency(amount: number): string {
|
|
34
57
|
return new Intl.NumberFormat("en-US", {
|
|
35
58
|
style: "currency",
|
|
@@ -205,6 +228,7 @@ export function OpportunitiesBoard() {
|
|
|
205
228
|
item={item}
|
|
206
229
|
dragging={dragId === item.id}
|
|
207
230
|
onOpen={() => setActiveId(item.id)}
|
|
231
|
+
onDelete={() => deleteItem(item.id)}
|
|
208
232
|
onDragStart={() => setDragId(item.id)}
|
|
209
233
|
onDragEnd={() => {
|
|
210
234
|
setDragId(null);
|
|
@@ -233,18 +257,27 @@ export function OpportunitiesBoard() {
|
|
|
233
257
|
</div>
|
|
234
258
|
|
|
235
259
|
{activeItem && (
|
|
236
|
-
<
|
|
237
|
-
|
|
260
|
+
<RecordFormPanel<Opportunity>
|
|
261
|
+
fields={OPPORTUNITY_FIELDS}
|
|
262
|
+
row={activeItem}
|
|
263
|
+
singular="Opportunity"
|
|
264
|
+
title="Opportunities"
|
|
265
|
+
icon={Target}
|
|
238
266
|
isNew={activeItem.id === newId}
|
|
267
|
+
getPrimary={(o) => ({
|
|
268
|
+
title: o.name || "Untitled opportunity",
|
|
269
|
+
initials: initials(o.name || "Opportunity"),
|
|
270
|
+
subtitle: o.company || undefined,
|
|
271
|
+
})}
|
|
239
272
|
onSave={(draft) => {
|
|
240
|
-
|
|
273
|
+
// The panel edits values as strings; coerce the numeric field back.
|
|
274
|
+
updateItem(activeItem.id, {
|
|
275
|
+
...draft,
|
|
276
|
+
amount: Number(draft.amount) || 0,
|
|
277
|
+
});
|
|
241
278
|
setNewId(null);
|
|
242
279
|
setActiveId(null);
|
|
243
280
|
}}
|
|
244
|
-
onDelete={() => {
|
|
245
|
-
deleteItem(activeItem.id);
|
|
246
|
-
setNewId(null);
|
|
247
|
-
}}
|
|
248
281
|
onCancel={() => {
|
|
249
282
|
// Discard a never-saved new card entirely.
|
|
250
283
|
if (activeItem.id === newId) deleteItem(activeItem.id);
|
|
@@ -261,12 +294,14 @@ function OpportunityCard({
|
|
|
261
294
|
item,
|
|
262
295
|
dragging,
|
|
263
296
|
onOpen,
|
|
297
|
+
onDelete,
|
|
264
298
|
onDragStart,
|
|
265
299
|
onDragEnd,
|
|
266
300
|
}: {
|
|
267
301
|
item: Opportunity;
|
|
268
302
|
dragging: boolean;
|
|
269
303
|
onOpen: () => void;
|
|
304
|
+
onDelete: () => void;
|
|
270
305
|
onDragStart: () => void;
|
|
271
306
|
onDragEnd: () => void;
|
|
272
307
|
}) {
|
|
@@ -280,11 +315,23 @@ function OpportunityCard({
|
|
|
280
315
|
}}
|
|
281
316
|
onDragEnd={onDragEnd}
|
|
282
317
|
className={cn(
|
|
283
|
-
"group cursor-grab rounded-md border border-border bg-card p-3 shadow-sm transition-colors hover:border-ring/40 active:cursor-grabbing",
|
|
318
|
+
"group relative cursor-grab rounded-md border border-border bg-card p-3 shadow-sm transition-colors hover:border-ring/40 active:cursor-grabbing",
|
|
284
319
|
dragging && "opacity-40",
|
|
285
320
|
)}
|
|
286
321
|
>
|
|
287
|
-
<
|
|
322
|
+
<button
|
|
323
|
+
type="button"
|
|
324
|
+
onClick={(e) => {
|
|
325
|
+
e.stopPropagation();
|
|
326
|
+
onDelete();
|
|
327
|
+
}}
|
|
328
|
+
aria-label={`Delete ${item.name || "opportunity"}`}
|
|
329
|
+
title="Delete"
|
|
330
|
+
className="absolute right-1 top-1 grid size-6 place-items-center rounded text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
|
|
331
|
+
>
|
|
332
|
+
<Trash2 className="size-3.5" />
|
|
333
|
+
</button>
|
|
334
|
+
<div className="flex items-start gap-1.5 pr-5">
|
|
288
335
|
<GripVertical className="mt-0.5 size-3.5 shrink-0 text-muted-foreground/50" />
|
|
289
336
|
<button
|
|
290
337
|
type="button"
|
|
@@ -310,175 +357,3 @@ function OpportunityCard({
|
|
|
310
357
|
</article>
|
|
311
358
|
);
|
|
312
359
|
}
|
|
313
|
-
|
|
314
|
-
interface DetailProps {
|
|
315
|
-
item: Opportunity;
|
|
316
|
-
/** New (unsaved) card — hides Delete; Cancel drops the card. */
|
|
317
|
-
isNew: boolean;
|
|
318
|
-
/** Commit the buffered draft to the board. */
|
|
319
|
-
onSave: (draft: Opportunity) => void;
|
|
320
|
-
onDelete: () => void;
|
|
321
|
-
/** Discard the draft (and the card if it was never saved). */
|
|
322
|
-
onCancel: () => void;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
function OpportunityDetailPanel({
|
|
326
|
-
item,
|
|
327
|
-
isNew,
|
|
328
|
-
onSave,
|
|
329
|
-
onDelete,
|
|
330
|
-
onCancel,
|
|
331
|
-
}: DetailProps) {
|
|
332
|
-
// Buffer edits locally; the board only changes on Save (like organizations).
|
|
333
|
-
const [draft, setDraft] = React.useState<Opportunity>(item);
|
|
334
|
-
React.useEffect(() => {
|
|
335
|
-
setDraft(item);
|
|
336
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
337
|
-
}, [item.id]);
|
|
338
|
-
const patch = (p: Partial<Opportunity>) => setDraft((d) => ({ ...d, ...p }));
|
|
339
|
-
|
|
340
|
-
return (
|
|
341
|
-
<>
|
|
342
|
-
<div
|
|
343
|
-
className="vui-overlay-in fixed inset-0 z-[55] bg-foreground/25"
|
|
344
|
-
onClick={onCancel}
|
|
345
|
-
aria-hidden="true"
|
|
346
|
-
/>
|
|
347
|
-
<aside
|
|
348
|
-
aria-label="Opportunity details"
|
|
349
|
-
className="vui-panel-in fixed inset-y-0 right-0 z-[60] flex w-full flex-col border-l border-border bg-background shadow-xl sm:w-[380px] sm:max-w-[90vw]"
|
|
350
|
-
>
|
|
351
|
-
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-4">
|
|
352
|
-
<Target className="size-4 shrink-0 text-muted-foreground" />
|
|
353
|
-
<span className="truncate font-semibold">
|
|
354
|
-
{draft.name || "Untitled opportunity"}
|
|
355
|
-
</span>
|
|
356
|
-
<Button
|
|
357
|
-
variant="ghost"
|
|
358
|
-
size="icon"
|
|
359
|
-
onClick={onCancel}
|
|
360
|
-
aria-label="Close"
|
|
361
|
-
className="ml-auto"
|
|
362
|
-
>
|
|
363
|
-
<X className="size-4" />
|
|
364
|
-
</Button>
|
|
365
|
-
</div>
|
|
366
|
-
|
|
367
|
-
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto p-4">
|
|
368
|
-
<div className="space-y-3">
|
|
369
|
-
<p className="font-medium uppercase tracking-wide text-muted-foreground">
|
|
370
|
-
Fields
|
|
371
|
-
</p>
|
|
372
|
-
|
|
373
|
-
<Field label="Name">
|
|
374
|
-
<Input
|
|
375
|
-
value={draft.name}
|
|
376
|
-
onChange={(e) => patch({ name: e.target.value })}
|
|
377
|
-
aria-label="Name"
|
|
378
|
-
className="h-8"
|
|
379
|
-
/>
|
|
380
|
-
</Field>
|
|
381
|
-
|
|
382
|
-
<Field label="Company" icon={Building2}>
|
|
383
|
-
<Input
|
|
384
|
-
value={draft.company}
|
|
385
|
-
onChange={(e) => patch({ company: e.target.value })}
|
|
386
|
-
aria-label="Company"
|
|
387
|
-
className="h-8"
|
|
388
|
-
/>
|
|
389
|
-
</Field>
|
|
390
|
-
|
|
391
|
-
<Field label="Amount" icon={Coins}>
|
|
392
|
-
<Input
|
|
393
|
-
type="number"
|
|
394
|
-
min={0}
|
|
395
|
-
value={draft.amount}
|
|
396
|
-
onChange={(e) => patch({ amount: Number(e.target.value) || 0 })}
|
|
397
|
-
aria-label="Amount"
|
|
398
|
-
className="h-8 tabular-nums"
|
|
399
|
-
/>
|
|
400
|
-
</Field>
|
|
401
|
-
|
|
402
|
-
<Field label="Stage" icon={ListFilter}>
|
|
403
|
-
<Dropdown label={draft.stage} align="start" active>
|
|
404
|
-
<DropdownLabel>Move to stage</DropdownLabel>
|
|
405
|
-
{OPPORTUNITY_STAGES.map((s) => (
|
|
406
|
-
<DropdownItem
|
|
407
|
-
key={s}
|
|
408
|
-
checked={draft.stage === s}
|
|
409
|
-
onSelect={() => patch({ stage: s })}
|
|
410
|
-
>
|
|
411
|
-
{s}
|
|
412
|
-
</DropdownItem>
|
|
413
|
-
))}
|
|
414
|
-
</Dropdown>
|
|
415
|
-
</Field>
|
|
416
|
-
|
|
417
|
-
<Field label="Owner" icon={User}>
|
|
418
|
-
<Input
|
|
419
|
-
value={draft.owner}
|
|
420
|
-
onChange={(e) => patch({ owner: e.target.value })}
|
|
421
|
-
aria-label="Owner"
|
|
422
|
-
className="h-8"
|
|
423
|
-
/>
|
|
424
|
-
</Field>
|
|
425
|
-
|
|
426
|
-
<Field label="Close date" icon={CalendarDays}>
|
|
427
|
-
<Input
|
|
428
|
-
type="date"
|
|
429
|
-
value={draft.closeDate}
|
|
430
|
-
onChange={(e) => patch({ closeDate: e.target.value })}
|
|
431
|
-
aria-label="Close date"
|
|
432
|
-
className="h-8"
|
|
433
|
-
/>
|
|
434
|
-
</Field>
|
|
435
|
-
</div>
|
|
436
|
-
</div>
|
|
437
|
-
|
|
438
|
-
{/* Footer — Add shows Cancel/Save; Edit adds a leading Delete.
|
|
439
|
-
Save uses the primary color; Delete uses the destructive (red) token. */}
|
|
440
|
-
<div className="flex shrink-0 items-center justify-end gap-2 border-y border-border bg-muted/40 px-4 py-3">
|
|
441
|
-
{!isNew && (
|
|
442
|
-
<Button
|
|
443
|
-
variant="destructive"
|
|
444
|
-
size="sm"
|
|
445
|
-
onClick={onDelete}
|
|
446
|
-
className="mr-auto"
|
|
447
|
-
>
|
|
448
|
-
<Trash2 className="size-4" />
|
|
449
|
-
Delete
|
|
450
|
-
</Button>
|
|
451
|
-
)}
|
|
452
|
-
<Button size="sm" onClick={onCancel}>
|
|
453
|
-
<X className="size-4" />
|
|
454
|
-
Cancel
|
|
455
|
-
</Button>
|
|
456
|
-
<Button variant="primary" size="sm" onClick={() => onSave(draft)}>
|
|
457
|
-
<Check className="size-4" />
|
|
458
|
-
Save
|
|
459
|
-
</Button>
|
|
460
|
-
</div>
|
|
461
|
-
</aside>
|
|
462
|
-
</>
|
|
463
|
-
);
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
function Field({
|
|
467
|
-
label,
|
|
468
|
-
icon: Icon,
|
|
469
|
-
children,
|
|
470
|
-
}: {
|
|
471
|
-
label: string;
|
|
472
|
-
icon?: React.ComponentType<{ className?: string }>;
|
|
473
|
-
children: React.ReactNode;
|
|
474
|
-
}) {
|
|
475
|
-
return (
|
|
476
|
-
<div className="space-y-1">
|
|
477
|
-
<span className="flex items-center gap-1.5 text-muted-foreground">
|
|
478
|
-
{Icon && <Icon className="size-3.5" />}
|
|
479
|
-
{label}
|
|
480
|
-
</span>
|
|
481
|
-
{children}
|
|
482
|
-
</div>
|
|
483
|
-
);
|
|
484
|
-
}
|