@viliha/vui-ui 1.4.0 → 1.4.3

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
@@ -76,11 +76,16 @@ command palette, nav config, logo) and **demo pages** are scaffolded with:
76
76
  npx @viliha/vui-ui init # interactive decision tree
77
77
  ```
78
78
 
79
- Files land in the consumer's repo (they own them). Two questions:
79
+ Files land in the consumer's repo (they own them). Questions:
80
+ 0. **Next.js or Turborepo?** (`--nextjs` / `--turbo` + `--dir <path>`, default
81
+ `apps/web` — turbo scaffolds into that app directory)
80
82
  1. **Fresh project?** (`--fresh` / `--existing`)
81
83
  2. **Pre-built theme?** (`--prebuilt` = shell + demo pages / `--theme-only` = just
82
84
  the theme wiring you configure)
83
85
 
86
+ For a fresh app, create it **without `--src-dir`** (the scaffold uses a root
87
+ `app/` + `@/*` → `./*` and writes `next.config.ts`).
88
+
84
89
  - **fresh + prebuilt** → full runnable app (config + shell + demo).
85
90
  - **fresh + theme-only** → just `globals.css` + `next.config` wiring; build your own.
86
91
  - **existing + prebuilt** → shell + demo added; config **never** overwritten —
package/README.md CHANGED
@@ -61,11 +61,16 @@ Files are copied into *your* repo, so you own and edit them.
61
61
  | **Fresh** | full runnable app: config + shell + demo pages | just the theme wiring (`globals.css`, `next.config`) — build your own pages |
62
62
  | **Existing** | shell + demo added; your config is **never** overwritten (prints merge steps) | nothing copied; prints the wiring steps |
63
63
 
64
+ It first asks whether this is a standalone **Next.js** app or a **Turborepo**
65
+ (for a monorepo it scaffolds into a target app dir, e.g. `apps/web`).
66
+
64
67
  ```
65
68
  Flags (for CI / agents, skip the prompts):
69
+ --nextjs | --turbo standalone app, or a Turborepo (Q0)
70
+ --dir <path> Turborepo target app dir (default apps/web)
66
71
  --fresh | --existing project type (Q1)
67
72
  --prebuilt | --theme-only pre-built shell + demo, or just the theme (Q2)
68
- --yes, -y accept defaults (fresh, prebuilt)
73
+ --yes, -y accept defaults (nextjs, fresh, prebuilt)
69
74
  --force overwrite existing files
70
75
  --dry-run preview without writing
71
76
  ```
@@ -75,9 +80,20 @@ After a **pre-built** run, install the peer deps it prints, then `npm run dev`
75
80
 
76
81
  ### Fresh + pre-built (recommended for new apps)
77
82
 
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.
83
+ Start from a `create-next-app` base **without `--src-dir`** (the scaffold uses a
84
+ root `app/` + `@/*` `./*`):
85
+
86
+ ```bash
87
+ npx create-next-app@latest my-app --ts --tailwind --app --no-src-dir --use-npm
88
+ cd my-app
89
+ npx @viliha/vui-ui init --nextjs --fresh --prebuilt
90
+ npm i @viliha/vui-ui # + the peer deps the CLI prints (incl. tw-animate-css)
91
+ npm run dev
92
+ ```
93
+
94
+ `init` writes `next.config.ts`, `tsconfig.json`, `app/globals.css`, the shell,
95
+ and the demo pages (overwriting the create-next-app boilerplate), so the demo
96
+ runs out of the box.
81
97
 
82
98
  ### ⚠️ Existing project — read this first
83
99
 
package/bin/vui.mjs CHANGED
@@ -46,7 +46,7 @@ const DEPS = [
46
46
  ].join(" ");
47
47
 
48
48
  const WIRING_STEPS = ` 1. Install peer deps (those your components use):
49
- npm i ${DEPS}
49
+ npm i @viliha/vui-ui ${DEPS}
50
50
  2. app/globals.css:
51
51
  @import "tailwindcss";
52
52
  @import "@viliha/vui-ui/theme.css";
@@ -64,9 +64,11 @@ Usage:
64
64
  init Set up the VUI theme in this project (interactive).
65
65
 
66
66
  Options:
67
+ --nextjs | --turbo standalone Next.js app, or a Turborepo (Q0)
68
+ --dir <path> Turborepo target app dir (default apps/web)
67
69
  --fresh | --existing project type (Q1)
68
70
  --prebuilt | --theme-only pre-built shell + demo, or just the theme (Q2)
69
- --yes, -y accept defaults (fresh, prebuilt)
71
+ --yes, -y accept defaults (nextjs, fresh, prebuilt)
70
72
  --force overwrite existing files
71
73
  --dry-run preview without writing
72
74
  `);
@@ -94,7 +96,7 @@ async function ask(question, def) {
94
96
 
95
97
  // Theme wiring config an existing project already has — never clobbered there.
96
98
  const WIRING = new Set([
97
- "next.config.mjs",
99
+ "next.config.ts",
98
100
  "postcss.config.mjs",
99
101
  ".env.example",
100
102
  "app/globals.css",
@@ -129,6 +131,27 @@ function allFiles(dir, out = []) {
129
131
  }
130
132
 
131
133
  async function main() {
134
+ const cwd = process.cwd();
135
+
136
+ // Q0 — standalone Next.js vs Turborepo (decides WHERE files land).
137
+ let turbo = has("--turbo") ? true : has("--nextjs") ? false : null;
138
+ if (turbo === null) {
139
+ const a = await ask(
140
+ "Is this a standalone Next.js app or a Turborepo? [N/t] ",
141
+ "n",
142
+ );
143
+ turbo = a.startsWith("t");
144
+ }
145
+ let appDir = ".";
146
+ if (turbo) {
147
+ const di = args.indexOf("--dir");
148
+ const flagDir = di >= 0 ? args[di + 1] : null;
149
+ appDir =
150
+ flagDir ||
151
+ (await ask("Target app directory inside the repo? [apps/web] ", "apps/web"));
152
+ }
153
+ const targetRoot = join(cwd, appDir);
154
+
132
155
  // Q1 — fresh vs existing.
133
156
  let fresh = has("--fresh") ? true : has("--existing") ? false : null;
134
157
  if (fresh === null) {
@@ -155,14 +178,13 @@ async function main() {
155
178
  return;
156
179
  }
157
180
 
158
- const cwd = process.cwd();
159
181
  const files = allFiles(TEMPLATE);
160
182
  let created = 0;
161
183
  let skipped = 0;
162
184
  const configSkipped = [];
163
185
 
164
- const label = `${fresh ? "fresh" : "existing"}, ${prebuilt ? "prebuilt" : "theme-only"}`;
165
- console.log(`\nScaffolding VUI (${label}) into ${cwd}${dry ? " [dry run]" : ""}\n`);
186
+ const label = `${turbo ? "turbo" : "nextjs"}, ${fresh ? "fresh" : "existing"}, ${prebuilt ? "prebuilt" : "theme-only"}`;
187
+ console.log(`\nScaffolding VUI (${label}) into ${targetRoot}${dry ? " [dry run]" : ""}\n`);
166
188
 
167
189
  for (const abs of files) {
168
190
  const rel = relative(TEMPLATE, abs);
@@ -183,7 +205,7 @@ async function main() {
183
205
  // Fresh overwrites boilerplate (create-next-app defaults); existing is
184
206
  // non-destructive so we never clobber the user's own files.
185
207
  const force = forceFlag || fresh;
186
- const dest = join(cwd, rel);
208
+ const dest = join(targetRoot, rel);
187
209
  if (existsSync(dest) && !force) {
188
210
  skipped++;
189
211
  console.log(` skip ${rel}`);
@@ -205,7 +227,7 @@ async function main() {
205
227
  if (fresh && prebuilt) {
206
228
  console.log(`
207
229
  Next steps:
208
- 1. npm i ${DEPS}
230
+ 1. npm i @viliha/vui-ui ${DEPS}
209
231
  2. npm run dev -> http://localhost:3000 (redirects to /dashboard)
210
232
 
211
233
  Everything is in YOUR repo — edit app/_components/nav-config.ts, set your logo
@@ -216,7 +238,7 @@ Everything is in YOUR repo — edit app/_components/nav-config.ts, set your logo
216
238
  Theme wired (globals.css + next.config). No shell or demo pages — build your own.
217
239
 
218
240
  Next steps:
219
- 1. npm i ${DEPS}
241
+ 1. npm i @viliha/vui-ui ${DEPS}
220
242
  2. Import components from @viliha/vui-ui/* in your pages, then: npm run dev
221
243
  `);
222
244
  } else {
@@ -231,6 +253,15 @@ The shell + demo were added under app/(app)/ and app/_components/ — review the
231
253
  ? `\nSkipped config: ${configSkipped.join(", ")}`
232
254
  : ""
233
255
  }
256
+ `);
257
+ }
258
+
259
+ if (turbo) {
260
+ console.log(`Turborepo notes (target: ${appDir}):
261
+ • Add "@viliha/vui-ui" to ${appDir}/package.json dependencies.
262
+ • Ensure your workspace globs include this app (pnpm-workspace.yaml / workspaces).
263
+ • Run install + dev from the repo root or with a filter, e.g.
264
+ pnpm --filter <app-name> dev
234
265
  `);
235
266
  }
236
267
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viliha/vui-ui",
3
- "version": "1.4.0",
3
+ "version": "1.4.3",
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",
@@ -18,3 +18,10 @@ NEXT_PUBLIC_LICENSE="MIT Licensed"
18
18
  # Max pages kept open in the tab strip (client-side). Default 5, min 1. Opening
19
19
  # more evicts the oldest tab with a warning.
20
20
  NEXT_PUBLIC_MAX_TABS="5"
21
+
22
+ # How a collapsed sidebar rail reveals a group's sub-items. One of:
23
+ # inline — expands underneath the icon, in the rail itself
24
+ # flyout-click — click the icon to open a floating panel beside the rail
25
+ # flyout-hover — hover the icon to open it (click still works too)
26
+ # Default: flyout-hover
27
+ NEXT_PUBLIC_SIDEBAR_GROUP_MODE="flyout-hover"
@@ -13,6 +13,7 @@ import {
13
13
  } from "@radix-ui/react-icons";
14
14
 
15
15
  import { cn } from "@/lib/utils";
16
+ import { Menu as MenuPanel } from "@viliha/vui-ui/menu";
16
17
  import { Logo } from "./logo";
17
18
  import { QuickActionsLauncher } from "./quick-actions";
18
19
  import { useOpenTabs } from "./open-tabs";
@@ -29,6 +30,25 @@ function isActive(pathname: string, href: string): boolean {
29
30
  return pathname === href || pathname.startsWith(`${href}/`);
30
31
  }
31
32
 
33
+ /** How a collapsed-rail group with sub-items reveals its children. Set via
34
+ * `NEXT_PUBLIC_SIDEBAR_GROUP_MODE` (build-time) — see .env.example. */
35
+ type CollapsedGroupMode = "inline" | "flyout-click" | "flyout-hover";
36
+ const COLLAPSED_GROUP_MODES: readonly CollapsedGroupMode[] = [
37
+ "inline",
38
+ "flyout-click",
39
+ "flyout-hover",
40
+ ];
41
+ function readCollapsedGroupMode(): CollapsedGroupMode {
42
+ const raw = process.env.NEXT_PUBLIC_SIDEBAR_GROUP_MODE;
43
+ return (COLLAPSED_GROUP_MODES as readonly string[]).includes(raw ?? "")
44
+ ? (raw as CollapsedGroupMode)
45
+ : "flyout-hover";
46
+ }
47
+ const GROUP_MODE = readCollapsedGroupMode();
48
+ /** Delay before a hover-flyout closes, so the cursor can travel diagonally
49
+ * from the trigger into the panel without it disappearing mid-move. */
50
+ const FLYOUT_HOVER_CLOSE_DELAY_MS = 150;
51
+
32
52
  /** Nav glyph. All icons share one size for a consistent left-aligned column.
33
53
  Top-level icons are bordered chips (from the global icon rule): inactive keep
34
54
  their brand color, active inverts (dark fill, light glyph). Sub-menu icons are
@@ -95,17 +115,109 @@ function SidebarBody({
95
115
  }
96
116
  return open;
97
117
  });
118
+ const usesFlyout = collapsed && GROUP_MODE !== "inline";
119
+
98
120
  const toggleGroup = (label: string) =>
99
121
  setOpenGroups((prev) => {
122
+ // Flyout modes show one group at a time (menu-style); inline mode lets
123
+ // several collapsed groups stay expanded at once, like the expanded rail.
124
+ if (usesFlyout) return prev.has(label) ? new Set() : new Set([label]);
100
125
  const next = new Set(prev);
101
126
  if (next.has(label)) next.delete(label);
102
127
  else next.add(label);
103
128
  return next;
104
129
  });
105
130
 
131
+ // flyout-hover: open on enter, close on leave after a short delay so the
132
+ // cursor can travel from the trigger into the panel. A pending close is
133
+ // cancelled if the pointer re-enters the trigger or the panel first.
134
+ const hoverCloseTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
135
+ const cancelHoverClose = () => {
136
+ if (hoverCloseTimer.current) {
137
+ clearTimeout(hoverCloseTimer.current);
138
+ hoverCloseTimer.current = null;
139
+ }
140
+ };
141
+ const scheduleHoverClose = () => {
142
+ cancelHoverClose();
143
+ hoverCloseTimer.current = setTimeout(() => {
144
+ setOpenGroups(new Set());
145
+ }, FLYOUT_HOVER_CLOSE_DELAY_MS);
146
+ };
147
+ const openGroupOnHover = (label: string) => {
148
+ cancelHoverClose();
149
+ setOpenGroups(new Set([label]));
150
+ };
151
+ React.useEffect(() => () => cancelHoverClose(), []);
152
+
153
+ // Flyout modes: the open group's panel renders `position: fixed` at a rect
154
+ // computed from its trigger button, so it escapes the nav's
155
+ // `overflow-y-auto` clipping instead of being cut off at the rail edge.
156
+ const navRef = React.useRef<HTMLElement | null>(null);
157
+ const flyoutRef = React.useRef<HTMLDivElement | null>(null);
158
+ const groupButtonRefs = React.useRef<Map<string, HTMLButtonElement>>(new Map());
159
+ const [flyoutPos, setFlyoutPos] = React.useState<{ top: number; left: number } | null>(null);
160
+ const openGroupLabel = usesFlyout ? Array.from(openGroups)[0] : undefined;
161
+
162
+ React.useLayoutEffect(() => {
163
+ if (!openGroupLabel) {
164
+ setFlyoutPos(null);
165
+ return;
166
+ }
167
+ const btn = groupButtonRefs.current.get(openGroupLabel);
168
+ if (!btn) return;
169
+ const rect = btn.getBoundingClientRect();
170
+ setFlyoutPos({ top: rect.top, left: rect.right + 4 });
171
+ }, [openGroupLabel]);
172
+
173
+ // Clamp the panel to the viewport once its real height is known — a group
174
+ // near the bottom of the rail would otherwise render partly (or fully)
175
+ // below the fold and be invisible.
176
+ React.useLayoutEffect(() => {
177
+ if (!flyoutPos || !flyoutRef.current) return;
178
+ const margin = 8;
179
+ const rect = flyoutRef.current.getBoundingClientRect();
180
+ const maxTop = Math.max(margin, window.innerHeight - rect.height - margin);
181
+ if (rect.top > maxTop) {
182
+ setFlyoutPos((prev) => (prev ? { ...prev, top: maxTop } : prev));
183
+ }
184
+ }, [flyoutPos]);
185
+
186
+ React.useEffect(() => {
187
+ if (!openGroupLabel) return;
188
+ const close = () => setOpenGroups(new Set());
189
+ const onPointerDown = (e: MouseEvent) => {
190
+ const target = e.target as Node;
191
+ if (navRef.current?.contains(target)) return;
192
+ if (flyoutRef.current?.contains(target)) return;
193
+ close();
194
+ };
195
+ const onKeyDown = (e: KeyboardEvent) => {
196
+ if (e.key === "Escape") close();
197
+ };
198
+ document.addEventListener("mousedown", onPointerDown);
199
+ document.addEventListener("keydown", onKeyDown);
200
+ window.addEventListener("resize", close);
201
+ return () => {
202
+ document.removeEventListener("mousedown", onPointerDown);
203
+ document.removeEventListener("keydown", onKeyDown);
204
+ window.removeEventListener("resize", close);
205
+ };
206
+ }, [openGroupLabel]);
207
+
208
+ const openGroupEntry = React.useMemo(() => {
209
+ if (!openGroupLabel) return undefined;
210
+ for (const section of NAV) {
211
+ for (const entry of section.items) {
212
+ if (isGroup(entry) && entry.label === openGroupLabel) return entry;
213
+ }
214
+ }
215
+ return undefined;
216
+ }, [openGroupLabel]);
217
+
106
218
  const { openTab } = useOpenTabs();
107
219
 
108
- const renderLink = (item: NavLink, sub = false) => {
220
+ const renderLink = (item: NavLink, sub = false, inFlyout = false) => {
109
221
  const active = isActive(pathname, item.href);
110
222
  const onClick = (e: React.MouseEvent) => {
111
223
  // ⌘/Ctrl+click → open in a background tab (stay on the current page),
@@ -115,27 +227,30 @@ function SidebarBody({
115
227
  openTab(item.href, { background: true });
116
228
  return;
117
229
  }
230
+ setOpenGroups((prev) => (collapsed && prev.size ? new Set() : prev));
118
231
  onNavigate?.();
119
232
  };
233
+ const showLabel = inFlyout || !collapsed;
120
234
  return (
121
235
  <Link
122
236
  key={item.href}
123
237
  href={item.href}
124
238
  onClick={onClick}
125
239
  aria-current={active ? "page" : undefined}
126
- title={collapsed ? `${item.label} — ⌘-click for a new tab` : undefined}
240
+ title={collapsed && !inFlyout ? `${item.label} — ⌘-click for a new tab` : undefined}
127
241
  className={cn(
128
- "group/nav flex items-center gap-2.5 rounded-md px-2 py-1.5 transition-colors",
242
+ "group/nav flex h-9 items-center rounded-md transition-colors",
243
+ collapsed && !inFlyout ? "w-9 justify-center px-0" : "gap-2.5 px-2",
129
244
  active
130
245
  ? "bg-sidebar-accent text-sidebar-foreground"
131
246
  : cn(
132
247
  "hover:bg-sidebar-accent hover:text-sidebar-foreground",
133
- sub ? "text-muted-foreground" : "text-sidebar-foreground",
248
+ sub && !inFlyout ? "text-muted-foreground" : "text-sidebar-foreground",
134
249
  ),
135
250
  )}
136
251
  >
137
- <NavIcon icon={item.icon} active={active} color={item.color} plain={sub} />
138
- {!collapsed && <span className="truncate">{item.label}</span>}
252
+ <NavIcon icon={item.icon} active={active} color={item.color} plain={sub && !inFlyout} />
253
+ {showLabel && <span className="truncate">{item.label}</span>}
139
254
  </Link>
140
255
  );
141
256
  };
@@ -147,9 +262,10 @@ function SidebarBody({
147
262
  const anyActive = entry.children.some((c) => isActive(pathname, c.href));
148
263
  const GroupIcon = entry.icon;
149
264
 
150
- // Collapsed rail: show the PARENT icon with an expand chevron beside it;
151
- // its children reveal inline (indented, smaller) when toggled open.
152
- if (collapsed)
265
+ // Collapsed rail — inline mode: the icon plus a small corner chevron
266
+ // badge (not inline beside it, so it never overflows the icon's own box);
267
+ // children reveal indented underneath when toggled open.
268
+ if (collapsed && GROUP_MODE === "inline")
153
269
  return (
154
270
  <div key={entry.label} className="space-y-1">
155
271
  <button
@@ -158,16 +274,14 @@ function SidebarBody({
158
274
  aria-expanded={open}
159
275
  title={entry.label}
160
276
  className={cn(
161
- "group/nav flex w-full items-center gap-0.5 rounded-md px-2 py-1.5 transition-colors",
162
- anyActive
163
- ? "text-sidebar-foreground"
164
- : "hover:bg-sidebar-accent",
277
+ "group/nav relative flex size-9 items-center justify-center rounded-md transition-colors",
278
+ anyActive ? "text-sidebar-foreground" : "hover:bg-sidebar-accent",
165
279
  )}
166
280
  >
167
281
  <NavIcon icon={GroupIcon} active={anyActive} color={entry.color} />
168
282
  <ChevronRight
169
283
  className={cn(
170
- "size-3 shrink-0 text-muted-foreground transition-transform",
284
+ "absolute right-0.5 top-1/2 size-2.5 shrink-0 -translate-y-1/2 text-muted-foreground transition-transform",
171
285
  open && "rotate-90",
172
286
  )}
173
287
  aria-hidden="true"
@@ -181,6 +295,37 @@ function SidebarBody({
181
295
  </div>
182
296
  );
183
297
 
298
+ // Collapsed rail — flyout modes: the icon alone represents the group
299
+ // (matching the leaf icons around it, no overlapping chevron); opening
300
+ // its children panel (rendered separately, fixed-positioned) is either
301
+ // click- or hover-triggered depending on GROUP_MODE.
302
+ if (collapsed)
303
+ return (
304
+ <button
305
+ key={entry.label}
306
+ ref={(el) => {
307
+ if (el) groupButtonRefs.current.set(entry.label, el);
308
+ else groupButtonRefs.current.delete(entry.label);
309
+ }}
310
+ type="button"
311
+ onClick={() => toggleGroup(entry.label)}
312
+ onMouseEnter={
313
+ GROUP_MODE === "flyout-hover" ? () => openGroupOnHover(entry.label) : undefined
314
+ }
315
+ onMouseLeave={GROUP_MODE === "flyout-hover" ? scheduleHoverClose : undefined}
316
+ aria-expanded={open}
317
+ aria-haspopup="menu"
318
+ title={entry.label}
319
+ className={cn(
320
+ "group/nav flex size-9 items-center justify-center rounded-md transition-colors",
321
+ open && "bg-sidebar-accent",
322
+ anyActive ? "text-sidebar-foreground" : "hover:bg-sidebar-accent",
323
+ )}
324
+ >
325
+ <NavIcon icon={GroupIcon} active={anyActive} color={entry.color} />
326
+ </button>
327
+ );
328
+
184
329
  return (
185
330
  <div key={entry.label} className="space-y-1">
186
331
  <button
@@ -188,7 +333,7 @@ function SidebarBody({
188
333
  onClick={() => toggleGroup(entry.label)}
189
334
  aria-expanded={open}
190
335
  className={cn(
191
- "group/nav flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 transition-colors",
336
+ "group/nav flex h-9 w-full items-center gap-2.5 rounded-md px-2 transition-colors",
192
337
  anyActive
193
338
  ? "text-sidebar-foreground"
194
339
  : "text-sidebar-foreground hover:bg-sidebar-accent",
@@ -220,8 +365,8 @@ function SidebarBody({
220
365
  <button
221
366
  type="button"
222
367
  className={cn(
223
- "flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-sidebar-accent",
224
- collapsed && "justify-center px-0",
368
+ "flex min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-sidebar-accent",
369
+ collapsed ? "w-9 shrink-0 justify-center px-0" : "flex-1",
225
370
  )}
226
371
  aria-label="Switch workspace"
227
372
  title={collapsed ? "Vui Starter" : undefined}
@@ -241,17 +386,12 @@ function SidebarBody({
241
386
 
242
387
  {/* Quick actions — fixed above the scrolling nav, with a full-width
243
388
  divider and comfortable top/bottom padding. */}
244
- <div
245
- className={cn(
246
- "border-b border-sidebar-border px-3 py-3",
247
- collapsed && "flex justify-center",
248
- )}
249
- >
389
+ <div className="border-b border-sidebar-border px-3 py-3">
250
390
  <QuickActionsLauncher collapsed={collapsed} />
251
391
  </div>
252
392
 
253
393
  {/* Navigation */}
254
- <nav className="flex-1 space-y-4 overflow-y-auto px-3 py-3">
394
+ <nav ref={navRef} className="flex-1 space-y-4 overflow-y-auto px-3 py-3">
255
395
  {NAV.map((section, index) => (
256
396
  <React.Fragment key={section.title ?? `section-${index}`}>
257
397
  {/* Collapsed group separator — a nav-level sibling so the nav's
@@ -274,6 +414,28 @@ function SidebarBody({
274
414
  ))}
275
415
  </nav>
276
416
 
417
+ {/* Collapsed-group flyout: fixed-positioned beside its trigger so it
418
+ escapes the nav's overflow-y-auto clipping instead of the old
419
+ inline-expand, which overflowed the rail and clipped the chevron. */}
420
+ {openGroupEntry && flyoutPos && (
421
+ <div
422
+ ref={flyoutRef}
423
+ style={{ top: flyoutPos.top, left: flyoutPos.left }}
424
+ onMouseEnter={GROUP_MODE === "flyout-hover" ? cancelHoverClose : undefined}
425
+ onMouseLeave={GROUP_MODE === "flyout-hover" ? scheduleHoverClose : undefined}
426
+ className="fixed z-50 min-w-44"
427
+ >
428
+ <MenuPanel
429
+ tabIndex={-1}
430
+ className="space-y-0.5 border-sidebar-border bg-sidebar p-1 shadow-lg"
431
+ >
432
+ <p className="truncate px-2 pb-1 pt-1 text-xs font-medium text-muted-foreground">
433
+ {openGroupEntry.label}
434
+ </p>
435
+ {openGroupEntry.children.map((child) => renderLink(child, false, true))}
436
+ </MenuPanel>
437
+ </div>
438
+ )}
277
439
  </>
278
440
  );
279
441
  }
@@ -124,15 +124,17 @@ export function QuickActionsLauncher({ collapsed = false }: { collapsed?: boolea
124
124
  aria-label="Quick actions"
125
125
  title="Quick actions (⌘K)"
126
126
  className={cn(
127
- "flex items-center gap-2 rounded-md border border-sidebar-border bg-background text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-foreground",
128
- collapsed ? "size-9 justify-center px-0" : "w-full px-2.5 py-2",
127
+ "flex h-9 items-center gap-2 whitespace-nowrap rounded-md border border-sidebar-border bg-background text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-foreground",
128
+ collapsed ? "w-9 justify-center px-0" : "w-full px-2.5",
129
129
  )}
130
130
  >
131
131
  <LightningBoltIcon className="size-4 shrink-0 text-amber-500" aria-hidden="true" />
132
132
  {!collapsed && (
133
133
  <>
134
- <span className="flex-1 text-left text-sm font-medium">Quick actions</span>
135
- <Shortcut keys={["⌘", "K"]} />
134
+ <span className="min-w-0 flex-1 truncate text-left text-sm font-medium">
135
+ Quick actions
136
+ </span>
137
+ <Shortcut keys={["⌘", "K"]} className="shrink-0" />
136
138
  </>
137
139
  )}
138
140
  </button>
@@ -1,5 +1,4 @@
1
1
  @import "tailwindcss";
2
- @import "tw-animate-css";
3
2
 
4
3
  /* Shared VUI Starter design system (tokens, theme mapping, base reset).
5
4
  Package path (not a relative one) so it survives folder moves. */
@@ -1,5 +1,6 @@
1
- /** @type {import('next').NextConfig} */
2
- const nextConfig = {
1
+ import type { NextConfig } from "next";
2
+
3
+ const nextConfig: NextConfig = {
3
4
  reactStrictMode: true,
4
5
  // VUI ships as TypeScript source — Next must transpile it.
5
6
  transpilePackages: ["@viliha/vui-ui"],
@@ -0,0 +1,40 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": [
5
+ "dom",
6
+ "dom.iterable",
7
+ "esnext"
8
+ ],
9
+ "allowJs": true,
10
+ "skipLibCheck": true,
11
+ "strict": true,
12
+ "noEmit": true,
13
+ "esModuleInterop": true,
14
+ "module": "esnext",
15
+ "moduleResolution": "bundler",
16
+ "resolveJsonModule": true,
17
+ "isolatedModules": true,
18
+ "jsx": "preserve",
19
+ "incremental": true,
20
+ "plugins": [
21
+ {
22
+ "name": "next"
23
+ }
24
+ ],
25
+ "paths": {
26
+ "@/*": [
27
+ "./*"
28
+ ]
29
+ }
30
+ },
31
+ "include": [
32
+ "next-env.d.ts",
33
+ "**/*.ts",
34
+ "**/*.tsx",
35
+ ".next/types/**/*.ts"
36
+ ],
37
+ "exclude": [
38
+ "node_modules"
39
+ ]
40
+ }