@open-mercato/ui 0.5.1-develop.2996.ce62fd491c → 0.5.1-develop.3036.f02c281f23

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.
@@ -14,7 +14,7 @@ const PopoverContent = React.forwardRef(({ className, align = "start", sideOffse
14
14
  align,
15
15
  sideOffset,
16
16
  className: cn(
17
- "z-dropdown min-w-[280px] rounded-md border bg-popover p-0 text-popover-foreground shadow-md outline-none",
17
+ "z-tooltip min-w-[280px] rounded-md border bg-popover p-0 text-popover-foreground shadow-md outline-none",
18
18
  "data-[state=open]:animate-in data-[state=closed]:animate-out",
19
19
  "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
20
20
  "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/primitives/popover.tsx"],
4
- "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport * as PopoverPrimitive from '@radix-ui/react-popover'\nimport { cn } from '@open-mercato/shared/lib/utils'\n\nexport const Popover = PopoverPrimitive.Root\n\nexport const PopoverTrigger = PopoverPrimitive.Trigger\n\nexport const PopoverAnchor = PopoverPrimitive.Anchor\n\nexport const PopoverClose = PopoverPrimitive.Close\n\nexport const PopoverContent = React.forwardRef<\n React.ElementRef<typeof PopoverPrimitive.Content>,\n React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>\n>(({ className, align = 'start', sideOffset = 4, ...props }, ref) => (\n <PopoverPrimitive.Portal>\n <PopoverPrimitive.Content\n ref={ref}\n align={align}\n sideOffset={sideOffset}\n className={cn(\n 'z-dropdown min-w-[280px] rounded-md border bg-popover p-0 text-popover-foreground shadow-md outline-none',\n 'data-[state=open]:animate-in data-[state=closed]:animate-out',\n 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',\n 'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',\n 'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',\n 'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',\n className\n )}\n {...props}\n />\n </PopoverPrimitive.Portal>\n))\nPopoverContent.displayName = PopoverPrimitive.Content.displayName\n"],
4
+ "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport * as PopoverPrimitive from '@radix-ui/react-popover'\nimport { cn } from '@open-mercato/shared/lib/utils'\n\nexport const Popover = PopoverPrimitive.Root\n\nexport const PopoverTrigger = PopoverPrimitive.Trigger\n\nexport const PopoverAnchor = PopoverPrimitive.Anchor\n\nexport const PopoverClose = PopoverPrimitive.Close\n\nexport const PopoverContent = React.forwardRef<\n React.ElementRef<typeof PopoverPrimitive.Content>,\n React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>\n>(({ className, align = 'start', sideOffset = 4, ...props }, ref) => (\n <PopoverPrimitive.Portal>\n <PopoverPrimitive.Content\n ref={ref}\n align={align}\n sideOffset={sideOffset}\n className={cn(\n 'z-tooltip min-w-[280px] rounded-md border bg-popover p-0 text-popover-foreground shadow-md outline-none',\n 'data-[state=open]:animate-in data-[state=closed]:animate-out',\n 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',\n 'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',\n 'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',\n 'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',\n className\n )}\n {...props}\n />\n </PopoverPrimitive.Portal>\n))\nPopoverContent.displayName = PopoverPrimitive.Content.displayName\n"],
5
5
  "mappings": ";AAmBI;AAjBJ,YAAY,WAAW;AACvB,YAAY,sBAAsB;AAClC,SAAS,UAAU;AAEZ,MAAM,UAAU,iBAAiB;AAEjC,MAAM,iBAAiB,iBAAiB;AAExC,MAAM,gBAAgB,iBAAiB;AAEvC,MAAM,eAAe,iBAAiB;AAEtC,MAAM,iBAAiB,MAAM,WAGlC,CAAC,EAAE,WAAW,QAAQ,SAAS,aAAa,GAAG,GAAG,MAAM,GAAG,QAC3D,oBAAC,iBAAiB,QAAjB,EACC;AAAA,EAAC,iBAAiB;AAAA,EAAjB;AAAA,IACC;AAAA,IACA;AAAA,IACA;AAAA,IACA,WAAW;AAAA,MACT;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA,IACC,GAAG;AAAA;AACN,GACF,CACD;AACD,eAAe,cAAc,iBAAiB,QAAQ;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ui",
3
- "version": "0.5.1-develop.2996.ce62fd491c",
3
+ "version": "0.5.1-develop.3036.f02c281f23",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -134,12 +134,12 @@
134
134
  "recharts": "^3.8.1"
135
135
  },
136
136
  "peerDependencies": {
137
- "@open-mercato/shared": "0.5.1-develop.2996.ce62fd491c",
137
+ "@open-mercato/shared": "0.5.1-develop.3036.f02c281f23",
138
138
  "react": ">=18.0.0",
139
139
  "react-dom": ">=18.0.0"
140
140
  },
141
141
  "devDependencies": {
142
- "@open-mercato/shared": "0.5.1-develop.2996.ce62fd491c",
142
+ "@open-mercato/shared": "0.5.1-develop.3036.f02c281f23",
143
143
  "@testing-library/dom": "^10.4.1",
144
144
  "@testing-library/jest-dom": "^6.9.1",
145
145
  "@testing-library/react": "^16.3.1",
@@ -3,7 +3,7 @@ import * as React from 'react'
3
3
  import { createContext, useContext } from 'react'
4
4
  import Link from 'next/link'
5
5
  import Image from 'next/image'
6
- import { ChevronDown, Search, X } from 'lucide-react'
6
+ import { ChevronDown, ChevronLeft, Search, X } from 'lucide-react'
7
7
  import { Button } from '../primitives/button'
8
8
  import { IconButton } from '../primitives/icon-button'
9
9
  import { Input } from '../primitives/input'
@@ -547,6 +547,25 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
547
547
  document.cookie = `om_sidebar_collapsed=${collapsed ? '1' : '0'}; path=/; max-age=31536000; samesite=lax`
548
548
  } catch { /* cookies disabled — non-critical */ }
549
549
  }, [collapsed])
550
+
551
+ // Two-level sidebar (Option B): when entering settings/profile mode, force the
552
+ // main sidebar to collapsed (icons only) so the section sub-nav can sit beside
553
+ // it; restore the user's previous expansion when returning to the main mode.
554
+ // Initial ref is 'main' so direct mounts on /backend/settings also auto-collapse.
555
+ const collapsedBeforeSectionRef = React.useRef<boolean | null>(null)
556
+ const previousSidebarModeRef = React.useRef<'main' | 'settings' | 'profile'>('main')
557
+ React.useEffect(() => {
558
+ const previous = previousSidebarModeRef.current
559
+ if (previous === 'main' && sidebarMode !== 'main') {
560
+ collapsedBeforeSectionRef.current = collapsed
561
+ if (!collapsed) setCollapsed(true)
562
+ } else if (previous !== 'main' && sidebarMode === 'main' && collapsedBeforeSectionRef.current !== null) {
563
+ const restoreTo = collapsedBeforeSectionRef.current
564
+ collapsedBeforeSectionRef.current = null
565
+ if (collapsed !== restoreTo) setCollapsed(restoreTo)
566
+ }
567
+ previousSidebarModeRef.current = sidebarMode
568
+ }, [sidebarMode, collapsed])
550
569
  React.useEffect(() => {
551
570
  try { localStorage.setItem('om:sidebarOpenGroups', JSON.stringify(openGroups)) } catch { /* localStorage blocked (private mode) — non-critical */ }
552
571
  }, [openGroups])
@@ -584,7 +603,8 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
584
603
  sections: SectionNavGroup[],
585
604
  title: string,
586
605
  compact: boolean,
587
- hideHeader?: boolean
606
+ hideHeader?: boolean,
607
+ hideSearch?: boolean
588
608
  ) {
589
609
  const sortedSections = [...sections].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
590
610
  const lastVisibleIndex = sortedSections.length - 1
@@ -603,7 +623,7 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
603
623
  </Link>
604
624
  </div>
605
625
  )}
606
- {!compact && (
626
+ {!compact && !hideSearch && (
607
627
  <Input
608
628
  type="text"
609
629
  value={navQuery}
@@ -628,13 +648,14 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
628
648
  <div data-sidebar-scroll="true" className={`flex flex-1 flex-col gap-3 overflow-y-auto scrollbar-hide pr-1 ${compact ? '-ml-2 pl-2' : '-ml-3 pl-3'}`}>
629
649
  <nav className="flex flex-col gap-2">
630
650
  {sortedSections.map((section, sectionIndex) => {
651
+ const sectionNavQueryActive = hideSearch ? false : navQueryActive
631
652
  const matchesItemQuery = (item: typeof section.items[number]): boolean => {
632
- if (!navQueryActive) return true
653
+ if (!sectionNavQueryActive) return true
633
654
  const label = item.labelKey ? t(item.labelKey, item.label) : item.label
634
655
  if (matchesQuery(label)) return true
635
656
  return Array.isArray(item.children) && item.children.some(matchesItemQuery)
636
657
  }
637
- const visibleItems = navQueryActive
658
+ const visibleItems = sectionNavQueryActive
638
659
  ? section.items.filter(matchesItemQuery)
639
660
  : section.items
640
661
  if (visibleItems.length === 0) return null
@@ -646,7 +667,7 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
646
667
  [...items].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
647
668
  const filterChildren = (children: typeof section.items | undefined) => {
648
669
  if (!children) return [] as typeof section.items
649
- if (!navQueryActive) return [...children]
670
+ if (!sectionNavQueryActive) return [...children]
650
671
  return children.filter(matchesItemQuery)
651
672
  }
652
673
 
@@ -661,7 +682,7 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
661
682
  pathname === child.href ||
662
683
  pathname.startsWith(`${child.href}/`)
663
684
  )))
664
- const showChildren = childItems.length > 0 && (isOnItemBranch || navQueryActive)
685
+ const showChildren = childItems.length > 0 && (isOnItemBranch || sectionNavQueryActive)
665
686
  const isActive = isOnItemBranch || hasActiveChild
666
687
  const base = compact ? 'w-10 h-10 justify-center' : 'w-full py-2 gap-2'
667
688
  const spacingStyle = !compact
@@ -731,7 +752,7 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
731
752
  )
732
753
  }
733
754
 
734
- function renderSidebar(compact: boolean, hideHeader?: boolean) {
755
+ function renderSidebar(compact: boolean, hideHeader?: boolean, forceMainOnly?: boolean) {
735
756
  if (!isChromeReady && isChromeLoading && resolvedGroups.length === 0) {
736
757
  return (
737
758
  <div className="flex flex-col min-h-full gap-3" data-testid="backend-chrome-loading">
@@ -768,7 +789,7 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
768
789
  )
769
790
  }
770
791
 
771
- if (sidebarMode === 'settings' && resolvedSettingsSections && resolvedSettingsSections.length > 0) {
792
+ if (!forceMainOnly && sidebarMode === 'settings' && resolvedSettingsSections && resolvedSettingsSections.length > 0) {
772
793
  const mergedSettingsSections = mergeSectionGroupsWithInjected(
773
794
  resolvedSettingsSections,
774
795
  settingsSidebarInjectedMenuItems,
@@ -782,7 +803,7 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
782
803
  )
783
804
  }
784
805
 
785
- if (sidebarMode === 'profile' && resolvedProfileSections && resolvedProfileSections.length > 0) {
806
+ if (!forceMainOnly && sidebarMode === 'profile' && resolvedProfileSections && resolvedProfileSections.length > 0) {
786
807
  const mergedProfileSections = mergeSectionGroupsWithInjected(
787
808
  resolvedProfileSections,
788
809
  profileSidebarInjectedMenuItems,
@@ -1013,9 +1034,49 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
1013
1034
  )
1014
1035
  }
1015
1036
 
1016
- const gridColsClass = effectiveCollapsed
1017
- ? 'lg:grid-cols-[80px_1fr]'
1018
- : 'lg:grid-cols-[240px_1fr]'
1037
+ function renderSectionAside() {
1038
+ let sections: SectionNavGroup[] | null = null
1039
+ let title = ''
1040
+ if (sidebarMode === 'settings' && resolvedSettingsSections && resolvedSettingsSections.length > 0) {
1041
+ sections = mergeSectionGroupsWithInjected(
1042
+ resolvedSettingsSections,
1043
+ settingsSidebarInjectedMenuItems,
1044
+ t,
1045
+ )
1046
+ title = settingsSectionTitle ?? t('backend.nav.settings', 'Settings')
1047
+ } else if (sidebarMode === 'profile' && resolvedProfileSections && resolvedProfileSections.length > 0) {
1048
+ sections = mergeSectionGroupsWithInjected(
1049
+ resolvedProfileSections,
1050
+ profileSidebarInjectedMenuItems,
1051
+ t,
1052
+ )
1053
+ title = profileSectionTitle ?? t('backend.nav.profile', 'Profile')
1054
+ }
1055
+ if (!sections) return null
1056
+ return (
1057
+ <div className="flex h-full flex-col gap-2">
1058
+ <Link
1059
+ href="/backend"
1060
+ className="inline-flex items-center gap-2 rounded-lg px-2 py-2 text-sm font-semibold text-foreground transition-colors hover:bg-muted"
1061
+ data-testid="appshell-section-back-to-main"
1062
+ aria-label={t('backend.nav.backToMain', 'Back to Main')}
1063
+ >
1064
+ <ChevronLeft className="size-4 shrink-0" aria-hidden />
1065
+ <span className="truncate">{title}</span>
1066
+ </Link>
1067
+ <div className="min-h-0 flex-1">
1068
+ {renderSectionSidebar(sections, title, false, true, true)}
1069
+ </div>
1070
+ </div>
1071
+ )
1072
+ }
1073
+
1074
+ const isSectionView =
1075
+ (sidebarMode === 'settings' && !!resolvedSettingsSections && resolvedSettingsSections.length > 0) ||
1076
+ (sidebarMode === 'profile' && !!resolvedProfileSections && resolvedProfileSections.length > 0)
1077
+ const gridColsClass = isSectionView
1078
+ ? (effectiveCollapsed ? 'lg:grid-cols-[80px_240px_1fr]' : 'lg:grid-cols-[240px_240px_1fr]')
1079
+ : (effectiveCollapsed ? 'lg:grid-cols-[80px_1fr]' : 'lg:grid-cols-[240px_1fr]')
1019
1080
  const headerCtxValue = React.useMemo(() => ({
1020
1081
  setBreadcrumb: setHeaderBreadcrumb,
1021
1082
  setTitle: setHeaderTitle,
@@ -1055,10 +1116,10 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
1055
1116
 
1056
1117
  return (
1057
1118
  <HeaderContext.Provider value={headerCtxValue}>
1058
- <div className={`min-h-svh lg:grid ${gridColsClass}`}>
1059
- {/* Desktop sidebar */}
1060
- <aside ref={sidebarAsideRef} className={`${asideClassesBase} ${effectiveCollapsed ? 'px-2' : 'px-3'} hidden lg:block lg:sticky lg:top-0 lg:h-svh lg:self-start lg:overflow-hidden lg:relative`} style={{ width: asideWidth }}>
1061
- {renderSidebar(effectiveCollapsed)}
1119
+ <div className={`min-h-svh lg:grid transition-[grid-template-columns] duration-200 ease-out ${gridColsClass}`}>
1120
+ {/* Desktop main sidebar */}
1121
+ <aside ref={sidebarAsideRef} className={`${asideClassesBase} ${effectiveCollapsed ? 'px-2' : 'px-3'} hidden lg:block lg:sticky lg:top-0 lg:h-svh lg:self-start lg:overflow-hidden lg:relative transition-[width,padding] duration-200 ease-out`} style={{ width: asideWidth }}>
1122
+ {renderSidebar(effectiveCollapsed, false, isSectionView)}
1062
1123
  {/* Scroll affordance — gradient fade + chevron that flips up when the user
1063
1124
  reaches the bottom and disappears when nothing is scrollable. */}
1064
1125
  {sidebarScrollState !== 'none' ? (
@@ -1077,6 +1138,26 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
1077
1138
  ) : null}
1078
1139
  </aside>
1079
1140
 
1141
+ {/* Desktop section sidebar (Option B two-level) — sits beside the main sidebar
1142
+ when the user is on settings/profile routes. Mobile drawer keeps the
1143
+ original swap behavior to fit the narrow width. */}
1144
+ {isSectionView ? (
1145
+ <aside
1146
+ className={`${asideClassesBase} px-3 hidden lg:block lg:sticky lg:top-0 lg:h-svh lg:self-start lg:overflow-hidden lg:relative`}
1147
+ style={{ width: '240px' }}
1148
+ data-testid="appshell-section-sidebar"
1149
+ >
1150
+ {renderSectionAside()}
1151
+ {/* Static bottom fade — covers the native iOS scroll indicator and signals
1152
+ that the section list is scrollable. Same look as the main sidebar's
1153
+ affordance but without the chevron / scroll-state machinery. */}
1154
+ <div
1155
+ aria-hidden
1156
+ className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-t from-background via-background/80 to-transparent"
1157
+ />
1158
+ </aside>
1159
+ ) : null}
1160
+
1080
1161
  <div className="flex min-h-svh flex-col min-w-0">
1081
1162
  <header className="border-b bg-background/80 px-3 lg:px-4 py-2 lg:py-3 flex items-center justify-between gap-2">
1082
1163
  <div
@@ -796,6 +796,12 @@ export function CrudForm<TValues extends Record<string, unknown>>({
796
796
  const target = resolveInternalNavigationTarget(href)
797
797
  if (!target) return
798
798
  if (shouldBypassUnsavedChangesGuardRef.current?.(target)) return
799
+ const baselineSnapshot = dirtyBaselineSnapshotRef.current
800
+ if (baselineSnapshot && JSON.stringify(valuesRef.current) === baselineSnapshot) {
801
+ isDirtyRef.current = false
802
+ setHasUnsavedChanges(false)
803
+ return
804
+ }
799
805
  event.preventDefault()
800
806
  event.stopPropagation()
801
807
  if (navigationConfirmPendingRef.current) return
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import * as React from 'react'
6
- import { screen, waitFor } from '@testing-library/react'
6
+ import { screen, waitFor, within } from '@testing-library/react'
7
7
  import { AppShell, ApplyBreadcrumb } from '../AppShell'
8
8
  import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
9
9
 
@@ -384,6 +384,158 @@ describe('AppShell', () => {
384
384
  }
385
385
  })
386
386
 
387
+ describe('two-level sidebar (settings/profile mode)', () => {
388
+ it('renders main + section sidebars side-by-side when on a settings path', async () => {
389
+ mockPathname = '/backend/entities/user'
390
+
391
+ const { container } = renderWithProviders(
392
+ <AppShell
393
+ email="demo@example.com"
394
+ groups={groups}
395
+ settingsPathPrefixes={['/backend/entities/user']}
396
+ settingsSections={[
397
+ {
398
+ id: 'data-designer',
399
+ label: 'Data Designer',
400
+ items: [
401
+ { id: 'user-entities', label: 'User Entities', href: '/backend/entities/user' },
402
+ ],
403
+ },
404
+ ]}
405
+ >
406
+ <div>Settings content</div>
407
+ </AppShell>,
408
+ { dict },
409
+ )
410
+
411
+ await waitFor(() => {
412
+ expect(screen.getByText('User Entities')).toBeInTheDocument()
413
+ })
414
+
415
+ const sectionAside = screen.getByTestId('appshell-section-sidebar')
416
+ expect(sectionAside).toBeInTheDocument()
417
+ expect(within(sectionAside).getByText('User Entities')).toBeInTheDocument()
418
+ // Main aside is auto-collapsed (icons only) when on a section path; the
419
+ // labels live in the `title` tooltip attribute, not as visible text.
420
+ expect(container.querySelector('a[href="/backend/users"]')).not.toBeNull()
421
+ expect(container.querySelector('a[href="/backend/roles"]')).not.toBeNull()
422
+ })
423
+
424
+ it('section header renders chevron + title as a single Back-to-Main link', async () => {
425
+ mockPathname = '/backend/entities/user'
426
+
427
+ renderWithProviders(
428
+ <AppShell
429
+ email="demo@example.com"
430
+ groups={groups}
431
+ settingsSectionTitle="Settings"
432
+ settingsPathPrefixes={['/backend/entities/user']}
433
+ settingsSections={[
434
+ {
435
+ id: 'data-designer',
436
+ label: 'Data Designer',
437
+ items: [
438
+ { id: 'user-entities', label: 'User Entities', href: '/backend/entities/user' },
439
+ ],
440
+ },
441
+ ]}
442
+ >
443
+ <div>Settings content</div>
444
+ </AppShell>,
445
+ { dict },
446
+ )
447
+
448
+ const backLink = await screen.findByTestId('appshell-section-back-to-main')
449
+ expect(backLink).toHaveAttribute('href', '/backend')
450
+ expect(backLink).toHaveAttribute('aria-label', 'Back to Main')
451
+ expect(backLink.textContent).toContain('Settings')
452
+ })
453
+
454
+ it('does not render a duplicate search input inside the section sidebar', async () => {
455
+ mockPathname = '/backend/entities/user'
456
+
457
+ renderWithProviders(
458
+ <AppShell
459
+ email="demo@example.com"
460
+ groups={groups}
461
+ settingsPathPrefixes={['/backend/entities/user']}
462
+ settingsSections={[
463
+ {
464
+ id: 'data-designer',
465
+ label: 'Data Designer',
466
+ items: [
467
+ { id: 'user-entities', label: 'User Entities', href: '/backend/entities/user' },
468
+ ],
469
+ },
470
+ ]}
471
+ >
472
+ <div>Settings content</div>
473
+ </AppShell>,
474
+ { dict },
475
+ )
476
+
477
+ const sectionAside = await screen.findByTestId('appshell-section-sidebar')
478
+ expect(within(sectionAside).queryByLabelText('Search navigation')).toBeNull()
479
+ })
480
+
481
+ it('auto-collapses the main sidebar to 80px when mounting directly on a settings path', async () => {
482
+ mockPathname = '/backend/entities/user'
483
+
484
+ const { container } = renderWithProviders(
485
+ <AppShell
486
+ email="demo@example.com"
487
+ groups={groups}
488
+ settingsPathPrefixes={['/backend/entities/user']}
489
+ settingsSections={[
490
+ {
491
+ id: 'data-designer',
492
+ label: 'Data Designer',
493
+ items: [
494
+ { id: 'user-entities', label: 'User Entities', href: '/backend/entities/user' },
495
+ ],
496
+ },
497
+ ]}
498
+ >
499
+ <div>Settings content</div>
500
+ </AppShell>,
501
+ { dict },
502
+ )
503
+
504
+ await waitFor(() => {
505
+ const mainAside = container.querySelector('aside') as HTMLElement | null
506
+ expect(mainAside).not.toBeNull()
507
+ expect(mainAside!.style.width).toBe('80px')
508
+ })
509
+ })
510
+
511
+ it('does not render the section sidebar when on a main route', async () => {
512
+ mockPathname = '/backend/users'
513
+
514
+ renderWithProviders(
515
+ <AppShell
516
+ email="demo@example.com"
517
+ groups={groups}
518
+ settingsPathPrefixes={['/backend/entities/user']}
519
+ settingsSections={[
520
+ {
521
+ id: 'data-designer',
522
+ label: 'Data Designer',
523
+ items: [
524
+ { id: 'user-entities', label: 'User Entities', href: '/backend/entities/user' },
525
+ ],
526
+ },
527
+ ]}
528
+ >
529
+ <div>Main content</div>
530
+ </AppShell>,
531
+ { dict },
532
+ )
533
+
534
+ expect(screen.queryByTestId('appshell-section-sidebar')).toBeNull()
535
+ expect(screen.queryByTestId('appshell-section-back-to-main')).toBeNull()
536
+ })
537
+ })
538
+
387
539
  it('renders nav icons from iconName when iconMarkup is missing', async () => {
388
540
  const previousFetch = global.fetch
389
541
  const previousWindowFetch = window.fetch
@@ -22,7 +22,7 @@ export const PopoverContent = React.forwardRef<
22
22
  align={align}
23
23
  sideOffset={sideOffset}
24
24
  className={cn(
25
- 'z-dropdown min-w-[280px] rounded-md border bg-popover p-0 text-popover-foreground shadow-md outline-none',
25
+ 'z-tooltip min-w-[280px] rounded-md border bg-popover p-0 text-popover-foreground shadow-md outline-none',
26
26
  'data-[state=open]:animate-in data-[state=closed]:animate-out',
27
27
  'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
28
28
  'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',