@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.2.0",
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",
@@ -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 selection toolbar offers a
195
- * "Set {label}" bulk action listing these values. */
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
- <textarea
1579
- value={String(draft[f.key as keyof T] ?? "")}
1580
- onChange={(e) => setField(f.key as keyof T, e.target.value)}
1581
- aria-label={f.label}
1582
- aria-invalid={errors.has(f.key) || undefined}
1583
- placeholder={`Add ${f.label.toLowerCase()}`}
1584
- rows={1}
1585
- // field-sizing grows the box to fit long/wrapped text
1586
- className={cn(
1587
- "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",
1588
- errors.has(f.key)
1589
- ? "border-destructive focus-visible:ring-destructive"
1590
- : "border-input focus-visible:ring-ring",
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
- <OpportunityDetailPanel
237
- item={activeItem}
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
- updateItem(activeItem.id, draft);
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
- <div className="flex items-start gap-1.5">
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
- }