@actuate-media/cms-admin 0.21.0 → 0.21.1
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/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +24 -0
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/layout/sidebar-submenu.render.test.d.ts +2 -0
- package/dist/__tests__/layout/sidebar-submenu.render.test.d.ts.map +1 -0
- package/dist/__tests__/layout/sidebar-submenu.render.test.js +60 -0
- package/dist/__tests__/layout/sidebar-submenu.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/ErrorBoundary.d.ts +1 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +101 -8
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/views/Forms.d.ts.map +1 -1
- package/dist/views/Forms.js +2 -1
- package/dist/views/Forms.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +22 -0
- package/src/__tests__/layout/sidebar-submenu.render.test.tsx +86 -0
- package/src/layout/Sidebar.tsx +224 -9
- package/src/views/Forms.tsx +3 -1
package/src/layout/Sidebar.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import type
|
|
3
|
+
import { useRef, useState, type ReactNode } from 'react'
|
|
4
|
+
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
|
5
|
+
import { useTheme } from '../components/ThemeProvider.js'
|
|
4
6
|
import {
|
|
5
7
|
LayoutDashboard,
|
|
6
8
|
FileText,
|
|
@@ -8,6 +10,7 @@ import {
|
|
|
8
10
|
Image,
|
|
9
11
|
Settings,
|
|
10
12
|
ChevronLeft,
|
|
13
|
+
ChevronDown,
|
|
11
14
|
ClipboardList,
|
|
12
15
|
Search as SearchIcon,
|
|
13
16
|
Briefcase,
|
|
@@ -271,36 +274,117 @@ function renderNavTree(items: NavItem[], ctx: NavRenderContext): ReactNode {
|
|
|
271
274
|
lastGroup = undefined
|
|
272
275
|
}
|
|
273
276
|
|
|
277
|
+
const hasChildren = !!item.children && item.children.length > 0
|
|
278
|
+
|
|
279
|
+
// Collapsed parents reveal their children in a hover/focus flyout instead
|
|
280
|
+
// of inline (there's no room for indented rows in the 80px rail).
|
|
281
|
+
if (hasChildren && ctx.collapsed) {
|
|
282
|
+
nodes.push(<CollapsedFlyout key={`flyout:${item.path}`} item={item} ctx={ctx} />)
|
|
283
|
+
continue
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Expanded parents with children render as a collapsible section
|
|
287
|
+
// (chevron toggle to contract/expand the submenu).
|
|
288
|
+
if (hasChildren) {
|
|
289
|
+
nodes.push(<NavSection key={`section:${item.path}`} item={item} ctx={ctx} />)
|
|
290
|
+
continue
|
|
291
|
+
}
|
|
292
|
+
|
|
274
293
|
// Keys are namespaced because a parent and its first child can share
|
|
275
294
|
// the same path (e.g. the Posts parent and its "All Posts" child are
|
|
276
295
|
// both `/posts`). Using the raw path as the key collided and made
|
|
277
296
|
// React warn about — and risk dropping — duplicate siblings.
|
|
278
297
|
nodes.push(<NavLink key={`item:${item.path}`} item={item} ctx={ctx} />)
|
|
279
|
-
if (item.children && item.children.length > 0 && !ctx.collapsed) {
|
|
280
|
-
for (const child of item.children) {
|
|
281
|
-
nodes.push(
|
|
282
|
-
<NavLink key={`child:${item.path}:${child.path}`} item={child} ctx={ctx} nested />,
|
|
283
|
-
)
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
298
|
}
|
|
287
299
|
|
|
288
300
|
return nodes
|
|
289
301
|
}
|
|
290
302
|
|
|
303
|
+
/**
|
|
304
|
+
* Expanded parent with a collapsible submenu. The label/icon navigates to the
|
|
305
|
+
* parent destination; a trailing chevron toggles the child list open/closed.
|
|
306
|
+
* Defaults to open, and auto-opens when the parent or one of its children is
|
|
307
|
+
* the active route so the current page is never hidden behind a collapsed
|
|
308
|
+
* section.
|
|
309
|
+
*/
|
|
310
|
+
function NavSection({ item, ctx }: { item: NavItem; ctx: NavRenderContext }) {
|
|
311
|
+
const Icon = item.icon
|
|
312
|
+
const isActive = isPathActive(ctx.currentPath, item.path)
|
|
313
|
+
const childActive = item.children!.some((c) => isPathActive(ctx.currentPath, c.path))
|
|
314
|
+
const [open, setOpen] = useState(true)
|
|
315
|
+
const childListId = `nav-section-${item.path.replace(/[^a-z0-9]+/gi, '-')}`
|
|
316
|
+
|
|
317
|
+
// Keep the active section visible: if the user is on this section's route
|
|
318
|
+
// (parent or child) it stays expanded regardless of the manual toggle.
|
|
319
|
+
const expanded = open || isActive || childActive
|
|
320
|
+
|
|
321
|
+
return (
|
|
322
|
+
<div>
|
|
323
|
+
<div
|
|
324
|
+
className={`flex items-center rounded-lg transition-colors ${
|
|
325
|
+
isActive
|
|
326
|
+
? 'bg-sidebar-accent text-sidebar-primary'
|
|
327
|
+
: 'text-sidebar-foreground hover:bg-sidebar-accent'
|
|
328
|
+
}`}
|
|
329
|
+
>
|
|
330
|
+
<button
|
|
331
|
+
onClick={() => ctx.onNavigate(item.path)}
|
|
332
|
+
className="flex min-w-0 flex-1 items-center gap-3 rounded-lg py-2.5 pr-1 pl-3 text-left"
|
|
333
|
+
aria-current={isActive ? 'page' : undefined}
|
|
334
|
+
>
|
|
335
|
+
<Icon className="h-5 w-5 shrink-0" aria-hidden />
|
|
336
|
+
<span className="truncate text-sm font-medium">{item.label}</span>
|
|
337
|
+
</button>
|
|
338
|
+
<button
|
|
339
|
+
onClick={() => setOpen((v) => !v)}
|
|
340
|
+
className="text-sidebar-foreground/55 hover:text-sidebar-foreground mr-1 flex shrink-0 items-center rounded-md p-1.5 transition-colors"
|
|
341
|
+
aria-label={`${expanded ? 'Collapse' : 'Expand'} ${item.label}`}
|
|
342
|
+
aria-expanded={expanded}
|
|
343
|
+
aria-controls={childListId}
|
|
344
|
+
>
|
|
345
|
+
<ChevronDown
|
|
346
|
+
className={`h-4 w-4 transition-transform duration-200 ${expanded ? '' : '-rotate-90'}`}
|
|
347
|
+
aria-hidden
|
|
348
|
+
/>
|
|
349
|
+
</button>
|
|
350
|
+
</div>
|
|
351
|
+
|
|
352
|
+
{/* Tight (gap-less) connector container so the vertical tree guides of
|
|
353
|
+
adjacent rows touch and read as one continuous line with curved
|
|
354
|
+
elbows into each child. */}
|
|
355
|
+
{expanded && (
|
|
356
|
+
<div id={childListId} className="relative">
|
|
357
|
+
{item.children!.map((child, i) => (
|
|
358
|
+
<NavLink
|
|
359
|
+
key={`child:${item.path}:${child.path}`}
|
|
360
|
+
item={child}
|
|
361
|
+
ctx={ctx}
|
|
362
|
+
nested
|
|
363
|
+
isLast={i === item.children!.length - 1}
|
|
364
|
+
/>
|
|
365
|
+
))}
|
|
366
|
+
</div>
|
|
367
|
+
)}
|
|
368
|
+
</div>
|
|
369
|
+
)
|
|
370
|
+
}
|
|
371
|
+
|
|
291
372
|
function NavLink({
|
|
292
373
|
item,
|
|
293
374
|
ctx,
|
|
294
375
|
nested = false,
|
|
376
|
+
isLast = false,
|
|
295
377
|
}: {
|
|
296
378
|
item: NavItem
|
|
297
379
|
ctx: NavRenderContext
|
|
298
380
|
nested?: boolean
|
|
381
|
+
/** Last child in its group — the elbow stops here (no continuation line). */
|
|
382
|
+
isLast?: boolean
|
|
299
383
|
}) {
|
|
300
384
|
const Icon = item.icon
|
|
301
385
|
const isActive = isPathActive(ctx.currentPath, item.path)
|
|
302
386
|
|
|
303
|
-
|
|
387
|
+
const button = (
|
|
304
388
|
<button
|
|
305
389
|
onClick={() => ctx.onNavigate(item.path)}
|
|
306
390
|
className={`flex w-full items-center gap-3 rounded-lg text-left transition-colors ${
|
|
@@ -324,6 +408,137 @@ function NavLink({
|
|
|
324
408
|
)}
|
|
325
409
|
</button>
|
|
326
410
|
)
|
|
411
|
+
|
|
412
|
+
if (!nested || ctx.collapsed) return button
|
|
413
|
+
|
|
414
|
+
// Curved-elbow tree connector (expanded only). The elbow draws the vertical
|
|
415
|
+
// run from the row top to its vertical center, curves at the bottom-left
|
|
416
|
+
// corner, then runs horizontally into the row. Non-last rows continue the
|
|
417
|
+
// vertical line to the row bottom so it meets the next sibling's elbow.
|
|
418
|
+
// The guide is centered (~21px) under the parent item's 20px icon.
|
|
419
|
+
return (
|
|
420
|
+
<div className="relative">
|
|
421
|
+
<span
|
|
422
|
+
aria-hidden
|
|
423
|
+
className="border-sidebar-border absolute top-0 left-[21px] h-1/2 w-[15px] rounded-bl-lg border-b border-l"
|
|
424
|
+
/>
|
|
425
|
+
{!isLast && (
|
|
426
|
+
<span
|
|
427
|
+
aria-hidden
|
|
428
|
+
className="border-sidebar-border absolute top-1/2 bottom-0 left-[21px] border-l"
|
|
429
|
+
/>
|
|
430
|
+
)}
|
|
431
|
+
{button}
|
|
432
|
+
</div>
|
|
433
|
+
)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Collapsed-rail parent: shows the icon button and, on hover or keyboard
|
|
438
|
+
* focus, a flyout listing the parent's children. Built on Radix
|
|
439
|
+
* DropdownMenu so the panel is portaled (escaping the nav's `overflow`
|
|
440
|
+
* clip), positioned, dismissable, and keyboard-navigable. Hover-opening is
|
|
441
|
+
* controlled manually; we suppress Radix's auto-focus when opened by pointer
|
|
442
|
+
* so hovering doesn't steal focus, while keyboard opening still moves focus
|
|
443
|
+
* into the menu.
|
|
444
|
+
*/
|
|
445
|
+
function CollapsedFlyout({ item, ctx }: { item: NavItem; ctx: NavRenderContext }) {
|
|
446
|
+
const Icon = item.icon
|
|
447
|
+
const isActive = isPathActive(ctx.currentPath, item.path)
|
|
448
|
+
const { resolvedTheme } = useTheme()
|
|
449
|
+
const [open, setOpen] = useState(false)
|
|
450
|
+
const openedByPointer = useRef(false)
|
|
451
|
+
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
452
|
+
|
|
453
|
+
const cancelClose = () => {
|
|
454
|
+
if (closeTimer.current) {
|
|
455
|
+
clearTimeout(closeTimer.current)
|
|
456
|
+
closeTimer.current = null
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const scheduleClose = () => {
|
|
460
|
+
cancelClose()
|
|
461
|
+
closeTimer.current = setTimeout(() => setOpen(false), 120)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return (
|
|
465
|
+
<DropdownMenu.Root
|
|
466
|
+
open={open}
|
|
467
|
+
onOpenChange={(next) => {
|
|
468
|
+
if (!next) openedByPointer.current = false
|
|
469
|
+
setOpen(next)
|
|
470
|
+
}}
|
|
471
|
+
modal={false}
|
|
472
|
+
>
|
|
473
|
+
<div
|
|
474
|
+
onMouseEnter={() => {
|
|
475
|
+
openedByPointer.current = true
|
|
476
|
+
cancelClose()
|
|
477
|
+
setOpen(true)
|
|
478
|
+
}}
|
|
479
|
+
onMouseLeave={scheduleClose}
|
|
480
|
+
>
|
|
481
|
+
<DropdownMenu.Trigger asChild>
|
|
482
|
+
<button
|
|
483
|
+
onClick={() => ctx.onNavigate(item.path)}
|
|
484
|
+
className={`flex w-full items-center justify-center rounded-lg px-3 py-2.5 transition-colors ${
|
|
485
|
+
isActive
|
|
486
|
+
? 'bg-sidebar-accent text-sidebar-primary'
|
|
487
|
+
: 'text-sidebar-foreground hover:bg-sidebar-accent'
|
|
488
|
+
}`}
|
|
489
|
+
aria-label={item.label}
|
|
490
|
+
title={item.label}
|
|
491
|
+
aria-current={isActive ? 'page' : undefined}
|
|
492
|
+
>
|
|
493
|
+
<Icon className="h-5 w-5 shrink-0" aria-hidden />
|
|
494
|
+
</button>
|
|
495
|
+
</DropdownMenu.Trigger>
|
|
496
|
+
</div>
|
|
497
|
+
|
|
498
|
+
<DropdownMenu.Portal>
|
|
499
|
+
<DropdownMenu.Content
|
|
500
|
+
side="right"
|
|
501
|
+
align="start"
|
|
502
|
+
sideOffset={10}
|
|
503
|
+
onMouseEnter={cancelClose}
|
|
504
|
+
onMouseLeave={scheduleClose}
|
|
505
|
+
onCloseAutoFocus={(e) => {
|
|
506
|
+
// After a pointer-driven open, don't yank focus back to the rail
|
|
507
|
+
// trigger (it would leave a stray focus ring once the cursor moves
|
|
508
|
+
// away). Keyboard-driven opens keep the default focus-return.
|
|
509
|
+
if (openedByPointer.current) e.preventDefault()
|
|
510
|
+
}}
|
|
511
|
+
// Radix portals to <body>, outside the `.actuate-admin` scope where
|
|
512
|
+
// the theme tokens (`--popover`, `--radius`, …) live — without the
|
|
513
|
+
// scope class the panel renders transparent with square corners.
|
|
514
|
+
// Re-assert the scope (and current dark state) on the panel itself so
|
|
515
|
+
// it inherits the tokens regardless of portal position.
|
|
516
|
+
className={`actuate-admin ${resolvedTheme === 'dark' ? 'dark' : ''} bg-popover text-popover-foreground border-border motion-safe:animate-in motion-safe:fade-in z-50 min-w-44 overflow-hidden rounded-lg border p-1 shadow-lg`}
|
|
517
|
+
>
|
|
518
|
+
<div className="text-muted-foreground border-border mb-1 border-b px-3 py-2 text-xs font-medium">
|
|
519
|
+
{item.label}
|
|
520
|
+
</div>
|
|
521
|
+
{item.children!.map((child) => {
|
|
522
|
+
const ChildIcon = child.icon
|
|
523
|
+
const childActive = isPathActive(ctx.currentPath, child.path)
|
|
524
|
+
return (
|
|
525
|
+
<DropdownMenu.Item
|
|
526
|
+
key={`flyout-item:${item.path}:${child.path}`}
|
|
527
|
+
onSelect={() => ctx.onNavigate(child.path)}
|
|
528
|
+
className={`data-highlighted:bg-accent hover:bg-accent flex cursor-pointer items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none select-none ${
|
|
529
|
+
childActive ? 'text-primary' : 'text-foreground'
|
|
530
|
+
}`}
|
|
531
|
+
aria-current={childActive ? 'page' : undefined}
|
|
532
|
+
>
|
|
533
|
+
<ChildIcon className="text-muted-foreground h-4 w-4 shrink-0" aria-hidden />
|
|
534
|
+
<span className="truncate">{child.label}</span>
|
|
535
|
+
</DropdownMenu.Item>
|
|
536
|
+
)
|
|
537
|
+
})}
|
|
538
|
+
</DropdownMenu.Content>
|
|
539
|
+
</DropdownMenu.Portal>
|
|
540
|
+
</DropdownMenu.Root>
|
|
541
|
+
)
|
|
327
542
|
}
|
|
328
543
|
|
|
329
544
|
// ─── Config-driven nav builder ──────────────────────────────────────────────
|
package/src/views/Forms.tsx
CHANGED
|
@@ -106,7 +106,9 @@ export function Forms({ onNavigate }: FormsProps) {
|
|
|
106
106
|
<div className="mb-3 flex items-center gap-4 text-sm text-gray-600">
|
|
107
107
|
<span className="flex items-center gap-1">
|
|
108
108
|
<FileText className="h-4 w-4" />
|
|
109
|
-
{form.
|
|
109
|
+
{form.fieldCount ??
|
|
110
|
+
(Array.isArray(form.fields) ? form.fields.length : form.fields)}{' '}
|
|
111
|
+
fields
|
|
110
112
|
</span>
|
|
111
113
|
<span className="flex items-center gap-1">
|
|
112
114
|
<Send className="h-4 w-4" />
|