@viliha/vui-ui 1.2.0 → 1.4.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
@@ -73,16 +73,23 @@ The package ships the components; the **app shell** (layout, sidebar, open tabs,
73
73
  command palette, nav config, logo) and **demo pages** are scaffolded with:
74
74
 
75
75
  ```bash
76
- npx @viliha/vui-ui init # interactive: fresh vs existing, with/without demo
76
+ npx @viliha/vui-ui init # interactive decision tree
77
77
  ```
78
78
 
79
- Files land in the consumer's repo (they own them). **Fresh project:** writes
80
- config too, runs out of the box. **Existing project:** `init --existing` never
81
- overwrites config it adds the shell/pages and prints the merge steps
82
- (`transpilePackages`, the `theme.css` import, the `@/*` alias, `import
83
- "./globals.css"`). Flags: `--fresh` / `--existing` / `--demo` / `--no-demo` /
84
- `--yes` / `--force` / `--dry-run`. If you only need components, skip `init` and
85
- just do the setup below.
79
+ Files land in the consumer's repo (they own them). Two questions:
80
+ 1. **Fresh project?** (`--fresh` / `--existing`)
81
+ 2. **Pre-built theme?** (`--prebuilt` = shell + demo pages / `--theme-only` = just
82
+ the theme wiring you configure)
83
+
84
+ - **fresh + prebuilt** full runnable app (config + shell + demo).
85
+ - **fresh + theme-only** → just `globals.css` + `next.config` wiring; build your own.
86
+ - **existing + prebuilt** → shell + demo added; config **never** overwritten —
87
+ prints the merge steps (`transpilePackages`, the `theme.css` import, the `@/*`
88
+ alias, `import "./globals.css"`).
89
+ - **existing + theme-only** → nothing copied; prints the wiring steps.
90
+
91
+ Other flags: `--yes` / `--force` / `--dry-run`. If you only need components, use
92
+ `--theme-only` (or skip `init`) and do the setup below.
86
93
 
87
94
  VUI ships as TypeScript source.
88
95
 
package/README.md CHANGED
@@ -48,32 +48,43 @@ pages** live in a one-shot scaffolder:
48
48
  npx @viliha/vui-ui init
49
49
  ```
50
50
 
51
- It's **interactive** — it asks whether this is a **fresh** or **existing**
52
- project and whether to include the **demo pages** (dashboard, CRM, calendar,
53
- chat, support, settings, auth). Files are copied into *your* repo, so you own and
54
- edit them. It's **non-destructive**: existing files are skipped.
51
+ It's **interactive** — a short decision tree:
52
+
53
+ 1. **Fresh project?** (fresh vs existing)
54
+ 2. **Pre-built theme?** the full shell + demo pages, or just the theme wiring
55
+ that you configure yourself.
56
+
57
+ Files are copied into *your* repo, so you own and edit them.
58
+
59
+ | | **Pre-built** (shell + demo) | **Theme-only** (you configure) |
60
+ | --- | --- | --- |
61
+ | **Fresh** | full runnable app: config + shell + demo pages | just the theme wiring (`globals.css`, `next.config`) — build your own pages |
62
+ | **Existing** | shell + demo added; your config is **never** overwritten (prints merge steps) | nothing copied; prints the wiring steps |
55
63
 
56
64
  ```
57
65
  Flags (for CI / agents, skip the prompts):
58
- --fresh | --existing project type
59
- --demo | --no-demo include demo pages (default: with demo)
60
- --yes, -y accept defaults (fresh, with demo)
61
- --force overwrite existing files
62
- --dry-run preview without writing
66
+ --fresh | --existing project type (Q1)
67
+ --prebuilt | --theme-only pre-built shell + demo, or just the theme (Q2)
68
+ --yes, -y accept defaults (fresh, prebuilt)
69
+ --force overwrite existing files
70
+ --dry-run preview without writing
63
71
  ```
64
72
 
65
- After it runs, install the peer deps it prints, then `npm run dev` → `/dashboard`.
73
+ After a **pre-built** run, install the peer deps it prints, then `npm run dev` →
74
+ `/dashboard`.
66
75
 
67
- ### Fresh project (recommended)
76
+ ### Fresh + pre-built (recommended for new apps)
68
77
 
69
- Start from a `create-next-app` base. `init --fresh` also writes `next.config.mjs`,
70
- `app/globals.css`, and the root layout, so the demo runs out of the box.
78
+ Start from a `create-next-app` base. This writes `next.config.mjs`,
79
+ `app/globals.css`, the shell, and the demo pages (overwriting the create-next-app
80
+ boilerplate), so the demo runs out of the box.
71
81
 
72
82
  ### ⚠️ Existing project — read this first
73
83
 
74
84
  Adding VUI to an app you already have needs care. **`init --existing` never
75
- overwrites your config** it adds the shell + pages under `app/(app)/` and
76
- `app/_components/`, then prints the four things to merge yourself:
85
+ overwrites your config.** Pick pre-built to add the shell + pages under
86
+ `app/(app)/` and `app/_components/`, or theme-only to copy nothing either way it
87
+ prints the four things to wire up:
77
88
 
78
89
  1. **`next.config`** — add `transpilePackages: ["@viliha/vui-ui"]`.
79
90
  (Optional, for the open-tabs *keep-alive*: `output: "export"`,
@@ -88,9 +99,9 @@ overwrites your config** — it adds the shell + pages under `app/(app)/` and
88
99
  4. **Root `app/layout.tsx`** — `import "./globals.css"` (and mount fonts to match
89
100
  the demo's look).
90
101
 
91
- Run **`npx @viliha/vui-ui init --existing --dry-run`** first to see exactly what
92
- it will add. If you only want the components (not the shell/pages), **skip `init`
93
- entirely** the [Setup](#setup) above is all you need, then import from
102
+ Run **`npx @viliha/vui-ui init --existing --prebuilt --dry-run`** first to see
103
+ exactly what it will add. If you only want the components, choose **theme-only**
104
+ (or skip `init` and follow the [Setup](#setup) above), then import from
94
105
  `@viliha/vui-ui/*`.
95
106
 
96
107
  > **Note on the theme in an existing app:** VUI owns its design tokens in
package/bin/vui.mjs CHANGED
@@ -1,11 +1,16 @@
1
1
  #!/usr/bin/env node
2
- // @viliha/vui-ui CLI — scaffolds the app shell + demo pages into a consumer's
3
- // Next.js project (shadcn-style: the files land in YOUR repo, you own them).
2
+ // @viliha/vui-ui CLI — sets up the theme in a consumer's Next.js project.
4
3
  //
5
4
  // npx @viliha/vui-ui init [options]
6
5
  //
7
- // Interactive by default: asks whether this is a fresh or existing project and
8
- // whether to include the demo pages. Non-interactive with flags (for CI/agents).
6
+ // Interactive decision tree (skip with flags):
7
+ // 1. Fresh project? --fresh | --existing
8
+ // 2. Pre-built theme + demo? --prebuilt | --theme-only
9
+ //
10
+ // fresh + prebuilt -> full runnable app: config + shell + demo pages
11
+ // fresh + theme-only -> just the theme wiring (globals/next.config), you build
12
+ // existing + prebuilt -> shell + demo added (config untouched; merge steps printed)
13
+ // existing + theme-only -> nothing copied; prints the wiring steps
9
14
  import { copyFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
10
15
  import { dirname, join, relative, sep } from "node:path";
11
16
  import { fileURLToPath } from "node:url";
@@ -16,6 +21,39 @@ const TEMPLATE = fileURLToPath(new URL("../template/", import.meta.url));
16
21
  const args = process.argv.slice(2);
17
22
  const cmd = args[0];
18
23
  const has = (f) => args.includes(f);
24
+ const dry = has("--dry-run");
25
+ const forceFlag = has("--force");
26
+
27
+ const DEPS = [
28
+ "@radix-ui/react-icons",
29
+ "@radix-ui/react-slot",
30
+ "@tanstack/react-table",
31
+ "class-variance-authority",
32
+ "clsx",
33
+ "cmdk",
34
+ "date-fns",
35
+ "input-otp",
36
+ "lucide-react",
37
+ "next-themes",
38
+ "radix-ui",
39
+ "react-day-picker",
40
+ "react-hook-form",
41
+ "@hookform/resolvers",
42
+ "recharts",
43
+ "sonner",
44
+ "tailwind-merge",
45
+ "zod",
46
+ ].join(" ");
47
+
48
+ const WIRING_STEPS = ` 1. Install peer deps (those your components use):
49
+ npm i ${DEPS}
50
+ 2. app/globals.css:
51
+ @import "tailwindcss";
52
+ @import "@viliha/vui-ui/theme.css";
53
+ 3. next.config: transpilePackages: ["@viliha/vui-ui"]
54
+ 4. tsconfig.json (only if you copy the reference-app pieces):
55
+ "compilerOptions": { "paths": { "@/*": ["./*"] } }
56
+ Then import components from @viliha/vui-ui/* and build your pages.`;
19
57
 
20
58
  function usage() {
21
59
  console.log(`@viliha/vui-ui
@@ -23,18 +61,14 @@ function usage() {
23
61
  Usage:
24
62
  npx @viliha/vui-ui init [options]
25
63
 
26
- init Scaffold the VUI app shell (+ optional demo pages) into this
27
- project. Interactive unless flags are given. Non-destructive:
28
- existing files are skipped (use --force to overwrite).
64
+ init Set up the VUI theme in this project (interactive).
29
65
 
30
66
  Options:
31
- --fresh Treat as a brand-new project (copies config too).
32
- --existing Treat as an existing project (skips config; prints merge steps).
33
- --demo Include the demo pages (dashboard, CRM, calendar, chat, …).
34
- --no-demo Shell + dashboard only, no other demo pages.
35
- --yes, -y Accept defaults without prompting (fresh, with demo).
36
- --force Overwrite files that already exist.
37
- --dry-run Show what would be written without touching the disk.
67
+ --fresh | --existing project type (Q1)
68
+ --prebuilt | --theme-only pre-built shell + demo, or just the theme (Q2)
69
+ --yes, -y accept defaults (fresh, prebuilt)
70
+ --force overwrite existing files
71
+ --dry-run preview without writing
38
72
  `);
39
73
  }
40
74
 
@@ -50,9 +84,6 @@ if (!existsSync(TEMPLATE)) {
50
84
  process.exit(1);
51
85
  }
52
86
 
53
- const dry = has("--dry-run");
54
- const force = has("--force");
55
-
56
87
  async function ask(question, def) {
57
88
  if (has("--yes") || has("-y") || !stdin.isTTY) return def;
58
89
  const rl = createInterface({ input: stdin, output: stdout });
@@ -61,80 +92,97 @@ async function ask(question, def) {
61
92
  return a || def;
62
93
  }
63
94
 
64
- // Config/root files that an existing project almost certainly already has —
65
- // copying them would collide, so in "existing" mode we skip + print merge steps.
66
- const CONFIG_FILES = new Set([
95
+ // Theme wiring config an existing project already has — never clobbered there.
96
+ const WIRING = new Set([
67
97
  "next.config.mjs",
68
98
  "postcss.config.mjs",
99
+ ".env.example",
69
100
  "app/globals.css",
70
- "app/layout.tsx",
71
- "app/page.tsx",
72
101
  ]);
73
102
 
74
- // A file is a "demo page" (optional) vs the shell (always). The dashboard stays
75
- // in the shell so `/` has a landing page.
103
+ // Optional demo pages (vs the always-present shell). Dashboard stays in the
104
+ // shell so `/` has a landing page.
76
105
  function isDemo(rel) {
77
106
  const p = rel.split(sep).join("/");
78
107
  if (p.startsWith("app/auth/")) return true;
79
108
  if (p.startsWith("app/onboarding/")) return true;
80
109
  if (p.startsWith("app/register-business/")) return true;
81
- if (p.startsWith("app/(app)/")) {
110
+ if (p.startsWith("app/(app)/"))
82
111
  return !(p === "app/(app)/layout.tsx" || p.startsWith("app/(app)/dashboard/"));
83
- }
84
112
  return false;
85
113
  }
86
114
 
115
+ function category(rel) {
116
+ const p = rel.split(sep).join("/");
117
+ if (WIRING.has(p)) return "wiring";
118
+ if (isDemo(rel)) return "demo";
119
+ return "shell";
120
+ }
121
+
122
+ function allFiles(dir, out = []) {
123
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
124
+ const abs = join(dir, entry.name);
125
+ if (entry.isDirectory()) allFiles(abs, out);
126
+ else out.push(abs);
127
+ }
128
+ return out;
129
+ }
130
+
87
131
  async function main() {
88
- // 1. Fresh vs existing.
89
- let mode = has("--existing") ? "existing" : has("--fresh") ? "fresh" : null;
90
- if (!mode) {
91
- const a = await ask(
92
- "Is this a fresh project or an existing one? [F(resh)/e(xisting)] ",
93
- "f",
94
- );
95
- mode = a.startsWith("e") ? "existing" : "fresh";
132
+ // Q1 fresh vs existing.
133
+ let fresh = has("--fresh") ? true : has("--existing") ? false : null;
134
+ if (fresh === null) {
135
+ const a = await ask("Are you installing VUI in a fresh project? [Y/n] ", "y");
136
+ fresh = !a.startsWith("n");
137
+ }
138
+
139
+ // Q2 — pre-built (shell + demo) vs theme-only.
140
+ let prebuilt = has("--prebuilt") ? true : has("--theme-only") ? false : null;
141
+ if (prebuilt === null) {
142
+ const q = fresh
143
+ ? "Install the pre-built theme with a Next.js project (shell + demo pages)? [Y/n] "
144
+ : "Install the pre-built theme (shell + demo pages) into this project? [Y/n] ";
145
+ const a = await ask(q, "y");
146
+ prebuilt = !a.startsWith("n");
96
147
  }
97
148
 
98
- // 2. Include the demo pages?
99
- let demo = has("--demo") ? true : has("--no-demo") ? false : null;
100
- if (demo === null) {
101
- const a = await ask(
102
- "Install the demo pages (dashboard, CRM, calendar, chat, support…)? [Y/n] ",
103
- "y",
149
+ // Existing + theme-only: nothing to copy — just print the wiring steps.
150
+ if (!fresh && !prebuilt) {
151
+ console.log(
152
+ `\nTheme-only setup for an existing project — no files copied.\n\n` +
153
+ `Wire the theme into your app:\n${WIRING_STEPS}\n`,
104
154
  );
105
- demo = !a.startsWith("n");
155
+ return;
106
156
  }
107
157
 
108
158
  const cwd = process.cwd();
159
+ const files = allFiles(TEMPLATE);
109
160
  let created = 0;
110
161
  let skipped = 0;
111
162
  const configSkipped = [];
112
163
 
113
- const files = [];
114
- (function walk(dir) {
115
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
116
- const abs = join(dir, entry.name);
117
- if (entry.isDirectory()) walk(abs);
118
- else files.push(abs);
119
- }
120
- })(TEMPLATE);
121
-
122
- console.log(
123
- `\nScaffolding VUI (${mode}${demo ? ", with demo" : ", no demo"}) into ${cwd}${
124
- dry ? " [dry run]" : ""
125
- }\n`,
126
- );
164
+ const label = `${fresh ? "fresh" : "existing"}, ${prebuilt ? "prebuilt" : "theme-only"}`;
165
+ console.log(`\nScaffolding VUI (${label}) into ${cwd}${dry ? " [dry run]" : ""}\n`);
127
166
 
128
167
  for (const abs of files) {
129
168
  const rel = relative(TEMPLATE, abs);
130
- const norm = rel.split(sep).join("/");
131
-
132
- if (isDemo(rel) && !demo) continue; // demo not selected
133
- if (mode === "existing" && CONFIG_FILES.has(norm)) {
134
- configSkipped.push(norm);
169
+ const cat = category(rel);
170
+
171
+ // Which files this leaf installs.
172
+ const include =
173
+ fresh && prebuilt
174
+ ? true // everything: config + shell + demo
175
+ : fresh && !prebuilt
176
+ ? cat === "wiring" // just the theme wiring
177
+ : /* existing && prebuilt */ cat !== "wiring"; // shell + demo, not config
178
+ if (!include) {
179
+ if (!fresh && cat === "wiring") configSkipped.push(rel.split(sep).join("/"));
135
180
  continue;
136
181
  }
137
182
 
183
+ // Fresh overwrites boilerplate (create-next-app defaults); existing is
184
+ // non-destructive so we never clobber the user's own files.
185
+ const force = forceFlag || fresh;
138
186
  const dest = join(cwd, rel);
139
187
  if (existsSync(dest) && !force) {
140
188
  skipped++;
@@ -153,59 +201,38 @@ async function main() {
153
201
  `\nDone — ${created} file(s) ${dry ? "would be added" : "added"}, ${skipped} skipped.`,
154
202
  );
155
203
 
156
- const DEPS = [
157
- "@radix-ui/react-icons",
158
- "@radix-ui/react-slot",
159
- "@tanstack/react-table",
160
- "class-variance-authority",
161
- "clsx",
162
- "cmdk",
163
- "date-fns",
164
- "input-otp",
165
- "lucide-react",
166
- "next-themes",
167
- "radix-ui",
168
- "react-day-picker",
169
- "react-hook-form",
170
- "@hookform/resolvers",
171
- "recharts",
172
- "sonner",
173
- "tailwind-merge",
174
- "zod",
175
- ].join(" ");
176
-
177
- if (mode === "existing") {
204
+ // Per-leaf next steps.
205
+ if (fresh && prebuilt) {
178
206
  console.log(`
179
- ⚠ Existing project — config files were NOT written to avoid clobbering yours.
180
- Merge these into your project manually:
181
-
182
- • next.config: add
183
- transpilePackages: ["@viliha/vui-ui"]
184
- (for the demo's tab keep-alive: output:"export", images:{unoptimized:true},
185
- trailingSlash:true — optional, only if you want static-export keep-alive)
186
- • app/globals.css: add
187
- @import "tailwindcss";
188
- @import "@viliha/vui-ui/theme.css";
189
- • tsconfig.json: map the alias the scaffold uses
190
- "compilerOptions": { "paths": { "@/*": ["./*"] } }
191
- • Root app/layout.tsx: import "./globals.css" and mount fonts if you want the
192
- demo's exact look (see the scaffolded app/(app)/layout.tsx for the shell).
207
+ Next steps:
208
+ 1. npm i ${DEPS}
209
+ 2. npm run dev -> http://localhost:3000 (redirects to /dashboard)
193
210
 
194
- The shell + pages were added under app/(app)/ and app/_components/ — review
195
- them (re-run with --dry-run first if unsure) before committing.
211
+ Everything is in YOUR repo edit app/_components/nav-config.ts, set your logo
212
+ (NEXT_PUBLIC_LOGO_URL in .env), and delete demo pages you don't need.
196
213
  `);
197
- }
198
-
199
- console.log(`Next steps:
200
- 1. Install peer dependencies:
201
- npm i ${DEPS}
202
- 2. Start the app:
203
- npm run dev -> http://localhost:3000 (redirects to /dashboard)
214
+ } else if (fresh && !prebuilt) {
215
+ console.log(`
216
+ Theme wired (globals.css + next.config). No shell or demo pages — build your own.
204
217
 
205
- Everything landed in YOUR repo — edit app/_components/nav-config.ts, set your
206
- logo (NEXT_PUBLIC_LOGO_URL in .env), and delete demo pages you don't need.
207
- Docs: /docs/navigation (sidebar, tabs) and /docs/configuration (logo, env).
218
+ Next steps:
219
+ 1. npm i ${DEPS}
220
+ 2. Import components from @viliha/vui-ui/* in your pages, then: npm run dev
208
221
  `);
222
+ } else {
223
+ // existing + prebuilt
224
+ console.log(`
225
+ ⚠ Existing project — config files were NOT written. Merge these yourself:
226
+ ${WIRING_STEPS}
227
+
228
+ The shell + demo were added under app/(app)/ and app/_components/ — review them
229
+ (re-run with --dry-run first if unsure) before committing.${
230
+ configSkipped.length
231
+ ? `\nSkipped config: ${configSkipped.join(", ")}`
232
+ : ""
233
+ }
234
+ `);
235
+ }
209
236
  }
210
237
 
211
238
  main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viliha/vui-ui",
3
- "version": "1.2.0",
3
+ "version": "1.4.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
- }