@open-mercato/ui 0.5.1-develop.2996.ce62fd491c → 0.5.1-develop.3032.01699048cb
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/backend/AppShell.js +84 -14
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +6 -0
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/primitives/popover.js +1 -1
- package/dist/primitives/popover.js.map +1 -1
- package/package.json +3 -3
- package/src/backend/AppShell.tsx +98 -17
- package/src/backend/CrudForm.tsx +6 -0
- package/src/backend/__tests__/AppShell.test.tsx +153 -1
- package/src/primitives/popover.tsx +1 -1
|
@@ -14,7 +14,7 @@ const PopoverContent = React.forwardRef(({ className, align = "start", sideOffse
|
|
|
14
14
|
align,
|
|
15
15
|
sideOffset,
|
|
16
16
|
className: cn(
|
|
17
|
-
"z-
|
|
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-
|
|
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.
|
|
3
|
+
"version": "0.5.1-develop.3032.01699048cb",
|
|
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.
|
|
137
|
+
"@open-mercato/shared": "0.5.1-develop.3032.01699048cb",
|
|
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.
|
|
142
|
+
"@open-mercato/shared": "0.5.1-develop.3032.01699048cb",
|
|
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",
|
package/src/backend/AppShell.tsx
CHANGED
|
@@ -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 (!
|
|
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 =
|
|
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 (!
|
|
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 ||
|
|
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
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
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
|
package/src/backend/CrudForm.tsx
CHANGED
|
@@ -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-
|
|
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',
|