@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.
@@ -1,6 +1,8 @@
1
1
  'use client'
2
2
 
3
- import type { ReactNode } from 'react'
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
- return (
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 ──────────────────────────────────────────────
@@ -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.fields} fields
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" />