@viliha/vui-ui 1.4.1 → 1.5.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 +8 -1
- package/README.md +34 -6
- package/bin/vui.mjs +58 -11
- package/package.json +1 -1
- package/template/.env.example +7 -0
- package/template/app/_components/app-sidebar.tsx +186 -24
- package/template/app/_components/quick-actions.tsx +6 -4
- package/template/app/globals.css +0 -1
- package/template/{next.config.mjs → next.config.ts} +3 -2
package/AGENT.md
CHANGED
|
@@ -76,11 +76,18 @@ 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).
|
|
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`). `init` **auto-installs the
|
|
88
|
+
dependencies** with the package manager it detects from the lockfile (npm / pnpm
|
|
89
|
+
/ yarn / bun); `--yes` skips the prompt, `--no-install` skips installing.
|
|
90
|
+
|
|
84
91
|
- **fresh + prebuilt** → full runnable app (config + shell + demo).
|
|
85
92
|
- **fresh + theme-only** → just `globals.css` + `next.config` wiring; build your own.
|
|
86
93
|
- **existing + prebuilt** → shell + demo added; config **never** overwritten —
|
package/README.md
CHANGED
|
@@ -61,23 +61,51 @@ 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
|
```
|
|
72
77
|
|
|
73
|
-
|
|
74
|
-
|
|
78
|
+
`init` **auto-installs the dependencies** with your package manager (detected
|
|
79
|
+
from the lockfile — npm, pnpm, yarn, or bun). It prompts first; pass `--yes` to
|
|
80
|
+
skip, or `--no-install` to do it yourself.
|
|
75
81
|
|
|
76
82
|
### Fresh + pre-built (recommended for new apps)
|
|
77
83
|
|
|
78
|
-
Start from a `create-next-app` base
|
|
79
|
-
`app
|
|
80
|
-
|
|
84
|
+
Start from a `create-next-app` base **without `--src-dir`** (the scaffold uses a
|
|
85
|
+
root `app/` + `@/*` → `./*`), then run `init` — it scaffolds, installs deps, and
|
|
86
|
+
you're ready for `dev`. Use whichever package manager you like:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
# npm
|
|
90
|
+
npx create-next-app@latest my-app --ts --tailwind --app --no-src-dir --use-npm
|
|
91
|
+
cd my-app && npx @viliha/vui-ui init && npm run dev
|
|
92
|
+
|
|
93
|
+
# pnpm
|
|
94
|
+
pnpm create next-app my-app --ts --tailwind --app --no-src-dir
|
|
95
|
+
cd my-app && pnpm dlx @viliha/vui-ui init && pnpm dev
|
|
96
|
+
|
|
97
|
+
# yarn
|
|
98
|
+
yarn create next-app my-app --ts --tailwind --app --no-src-dir
|
|
99
|
+
cd my-app && yarn dlx @viliha/vui-ui init && yarn dev
|
|
100
|
+
|
|
101
|
+
# bun
|
|
102
|
+
bun create next-app my-app --ts --tailwind --app --no-src-dir
|
|
103
|
+
cd my-app && bunx @viliha/vui-ui init && bun dev
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`init` writes `next.config.ts`, `tsconfig.json`, `app/globals.css`, the shell,
|
|
107
|
+
and the demo pages (overwriting the create-next-app boilerplate) and installs the
|
|
108
|
+
deps, so the demo runs out of the box at `/dashboard`.
|
|
81
109
|
|
|
82
110
|
### ⚠️ Existing project — read this first
|
|
83
111
|
|
package/bin/vui.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { dirname, join, relative, sep } from "node:path";
|
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
17
|
import { createInterface } from "node:readline/promises";
|
|
18
18
|
import { stdin, stdout } from "node:process";
|
|
19
|
+
import { execSync } from "node:child_process";
|
|
19
20
|
|
|
20
21
|
const TEMPLATE = fileURLToPath(new URL("../template/", import.meta.url));
|
|
21
22
|
const args = process.argv.slice(2);
|
|
@@ -42,13 +43,11 @@ const DEPS = [
|
|
|
42
43
|
"recharts",
|
|
43
44
|
"sonner",
|
|
44
45
|
"tailwind-merge",
|
|
45
|
-
// theme's globals.css imports these:
|
|
46
|
-
"tw-animate-css",
|
|
47
46
|
"zod",
|
|
48
47
|
].join(" ");
|
|
49
48
|
|
|
50
49
|
const WIRING_STEPS = ` 1. Install peer deps (those your components use):
|
|
51
|
-
npm i ${DEPS}
|
|
50
|
+
npm i @viliha/vui-ui ${DEPS}
|
|
52
51
|
2. app/globals.css:
|
|
53
52
|
@import "tailwindcss";
|
|
54
53
|
@import "@viliha/vui-ui/theme.css";
|
|
@@ -98,7 +97,7 @@ async function ask(question, def) {
|
|
|
98
97
|
|
|
99
98
|
// Theme wiring config an existing project already has — never clobbered there.
|
|
100
99
|
const WIRING = new Set([
|
|
101
|
-
"next.config.
|
|
100
|
+
"next.config.ts",
|
|
102
101
|
"postcss.config.mjs",
|
|
103
102
|
".env.example",
|
|
104
103
|
"app/globals.css",
|
|
@@ -123,6 +122,21 @@ function category(rel) {
|
|
|
123
122
|
return "shell";
|
|
124
123
|
}
|
|
125
124
|
|
|
125
|
+
// Detect the package manager from lockfiles (falls back to npm).
|
|
126
|
+
function detectPM(root) {
|
|
127
|
+
if (existsSync(join(root, "pnpm-lock.yaml"))) return "pnpm";
|
|
128
|
+
if (existsSync(join(root, "yarn.lock"))) return "yarn";
|
|
129
|
+
if (existsSync(join(root, "bun.lockb")) || existsSync(join(root, "bun.lock")))
|
|
130
|
+
return "bun";
|
|
131
|
+
return "npm";
|
|
132
|
+
}
|
|
133
|
+
const ADD_CMD = {
|
|
134
|
+
npm: "npm install",
|
|
135
|
+
pnpm: "pnpm add",
|
|
136
|
+
yarn: "yarn add",
|
|
137
|
+
bun: "bun add",
|
|
138
|
+
};
|
|
139
|
+
|
|
126
140
|
function allFiles(dir, out = []) {
|
|
127
141
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
128
142
|
const abs = join(dir, entry.name);
|
|
@@ -225,12 +239,46 @@ async function main() {
|
|
|
225
239
|
`\nDone — ${created} file(s) ${dry ? "would be added" : "added"}, ${skipped} skipped.`,
|
|
226
240
|
);
|
|
227
241
|
|
|
242
|
+
// Install dependencies. Turborepo installs are workspace-specific, so we only
|
|
243
|
+
// auto-install for a standalone Next.js app; turbo gets manual instructions.
|
|
244
|
+
const pm = detectPM(targetRoot);
|
|
245
|
+
const installList = `@viliha/vui-ui ${DEPS}`;
|
|
246
|
+
let installed = false;
|
|
247
|
+
if (!turbo) {
|
|
248
|
+
let doInstall = has("--no-install")
|
|
249
|
+
? false
|
|
250
|
+
: has("--yes") || has("-y")
|
|
251
|
+
? true
|
|
252
|
+
: null;
|
|
253
|
+
if (doInstall === null) {
|
|
254
|
+
const a = await ask(`Install dependencies now with ${pm}? [Y/n] `, "y");
|
|
255
|
+
doInstall = !a.startsWith("n");
|
|
256
|
+
}
|
|
257
|
+
if (doInstall && !dry) {
|
|
258
|
+
console.log(`\nInstalling dependencies with ${pm}…\n`);
|
|
259
|
+
try {
|
|
260
|
+
execSync(`${ADD_CMD[pm]} ${installList}`, {
|
|
261
|
+
cwd: targetRoot,
|
|
262
|
+
stdio: "inherit",
|
|
263
|
+
});
|
|
264
|
+
installed = true;
|
|
265
|
+
} catch {
|
|
266
|
+
console.error(
|
|
267
|
+
`\nInstall failed — run it manually:\n ${ADD_CMD[pm]} ${installList}\n`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
228
273
|
// Per-leaf next steps.
|
|
274
|
+
const installStep = installed
|
|
275
|
+
? ""
|
|
276
|
+
: ` 1. ${ADD_CMD[pm]} ${installList}\n`;
|
|
277
|
+
const devStepNum = installed ? "1" : "2";
|
|
229
278
|
if (fresh && prebuilt) {
|
|
230
279
|
console.log(`
|
|
231
280
|
Next steps:
|
|
232
|
-
|
|
233
|
-
2. npm run dev -> http://localhost:3000 (redirects to /dashboard)
|
|
281
|
+
${installStep} ${devStepNum}. ${pm === "npm" ? "npm run dev" : `${pm} dev`} -> http://localhost:3000 (redirects to /dashboard)
|
|
234
282
|
|
|
235
283
|
Everything is in YOUR repo — edit app/_components/nav-config.ts, set your logo
|
|
236
284
|
(NEXT_PUBLIC_LOGO_URL in .env), and delete demo pages you don't need.
|
|
@@ -240,8 +288,7 @@ Everything is in YOUR repo — edit app/_components/nav-config.ts, set your logo
|
|
|
240
288
|
Theme wired (globals.css + next.config). No shell or demo pages — build your own.
|
|
241
289
|
|
|
242
290
|
Next steps:
|
|
243
|
-
|
|
244
|
-
2. Import components from @viliha/vui-ui/* in your pages, then: npm run dev
|
|
291
|
+
${installStep} ${devStepNum}. Import components from @viliha/vui-ui/* in your pages, then run dev.
|
|
245
292
|
`);
|
|
246
293
|
} else {
|
|
247
294
|
// existing + prebuilt
|
|
@@ -260,10 +307,10 @@ The shell + demo were added under app/(app)/ and app/_components/ — review the
|
|
|
260
307
|
|
|
261
308
|
if (turbo) {
|
|
262
309
|
console.log(`Turborepo notes (target: ${appDir}):
|
|
263
|
-
• Add
|
|
310
|
+
• Add the deps to that app: ${ADD_CMD[pm]} ${installList}
|
|
311
|
+
(from the app dir, or with your PM's workspace filter).
|
|
264
312
|
• Ensure your workspace globs include this app (pnpm-workspace.yaml / workspaces).
|
|
265
|
-
• Run
|
|
266
|
-
pnpm --filter <app-name> dev
|
|
313
|
+
• Run dev with a filter, e.g. ${pm} --filter <app-name> dev
|
|
267
314
|
`);
|
|
268
315
|
}
|
|
269
316
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@viliha/vui-ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.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/template/.env.example
CHANGED
|
@@ -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
|
|
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
|
-
{
|
|
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:
|
|
151
|
-
//
|
|
152
|
-
|
|
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
|
|
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-
|
|
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
|
|
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
|
|
224
|
-
collapsed
|
|
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 ? "
|
|
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">
|
|
135
|
-
|
|
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>
|
package/template/app/globals.css
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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"],
|