@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 +6 -1
- package/README.md +20 -4
- package/bin/vui.mjs +40 -9
- 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/template/tsconfig.json +40 -0
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).
|
|
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
|
|
79
|
-
`app
|
|
80
|
-
|
|
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.
|
|
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 ${
|
|
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(
|
|
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.
|
|
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",
|
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"],
|
|
@@ -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
|
+
}
|