@orsetra/shared-ui 1.5.31 → 1.6.0
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.
|
@@ -25,7 +25,9 @@ export {
|
|
|
25
25
|
SidebarRail,
|
|
26
26
|
SidebarSeparator,
|
|
27
27
|
SidebarTrigger,
|
|
28
|
-
useSidebar
|
|
28
|
+
useSidebar,
|
|
29
|
+
useSidebarNavigation,
|
|
30
|
+
useInjectSidebarNavigation,
|
|
29
31
|
} from './sidebar/sidebar'
|
|
30
32
|
// Skeleton is exported from ../ui to avoid duplicate exports
|
|
31
33
|
export { RootLayoutWrapper, ibmPlexSans, ibmPlexMono } from './root-layout-wrapper'
|
|
@@ -46,10 +46,11 @@ export interface MainMenuItem {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
export interface SubMenuItem {
|
|
49
|
-
id:
|
|
50
|
-
name:
|
|
51
|
-
href:
|
|
52
|
-
icon:
|
|
49
|
+
id: string
|
|
50
|
+
name: string
|
|
51
|
+
href: string
|
|
52
|
+
icon: LucideIcon
|
|
53
|
+
applied?: boolean
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
export interface SidebarMenus {
|
|
@@ -23,6 +23,45 @@ import { Logo } from "../../ui/logo"
|
|
|
23
23
|
import { type SidebarMenus, type SubMenuItem, type MainMenuItem } from "./data"
|
|
24
24
|
import { Skeleton } from "../skeleton"
|
|
25
25
|
|
|
26
|
+
// ── Dynamic secondary navigation ──────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
interface SidebarNavigationContextValue {
|
|
29
|
+
injectedItems: SubMenuItem[] | null | undefined // null = not injected, undefined = injected but loading
|
|
30
|
+
injectedTitle: string | null
|
|
31
|
+
setInjectedItems: React.Dispatch<React.SetStateAction<SubMenuItem[] | null | undefined>>
|
|
32
|
+
setInjectedTitle: React.Dispatch<React.SetStateAction<string | null>>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const SidebarNavigationContext = React.createContext<SidebarNavigationContextValue>({
|
|
36
|
+
injectedItems: null,
|
|
37
|
+
injectedTitle: null,
|
|
38
|
+
setInjectedItems: () => {},
|
|
39
|
+
setInjectedTitle: () => {},
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
export function useSidebarNavigation() {
|
|
43
|
+
return React.useContext(SidebarNavigationContext)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Call inside a detail page/layout to replace the sidebar's secondary nav
|
|
48
|
+
* (and optionally its section title) for as long as the component is mounted.
|
|
49
|
+
* `items` must be referentially stable (useMemo / defined outside render).
|
|
50
|
+
*/
|
|
51
|
+
export function useInjectSidebarNavigation(items: SubMenuItem[] | undefined, title?: string) {
|
|
52
|
+
const { setInjectedItems, setInjectedTitle } = useSidebarNavigation()
|
|
53
|
+
React.useEffect(() => {
|
|
54
|
+
setInjectedItems(items)
|
|
55
|
+
setInjectedTitle(title ?? null)
|
|
56
|
+
return () => {
|
|
57
|
+
setInjectedItems(null)
|
|
58
|
+
setInjectedTitle(null)
|
|
59
|
+
}
|
|
60
|
+
}, [items, title]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Sidebar storage ───────────────────────────────────────────────────────────
|
|
64
|
+
|
|
26
65
|
const SIDEBAR_STORAGE_KEY = "sidebar:state"
|
|
27
66
|
const SIDEBAR_WIDTH = "14rem"
|
|
28
67
|
const SIDEBAR_WIDTH_ICON = "3rem"
|
|
@@ -142,35 +181,46 @@ const SidebarProvider = React.forwardRef<
|
|
|
142
181
|
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
|
143
182
|
)
|
|
144
183
|
|
|
184
|
+
const [injectedItems, setInjectedItems] = React.useState<SubMenuItem[] | null | undefined>(null)
|
|
185
|
+
const [injectedTitle, setInjectedTitle] = React.useState<string | null>(null)
|
|
186
|
+
const navContextValue = React.useMemo<SidebarNavigationContextValue>(
|
|
187
|
+
() => ({ injectedItems, injectedTitle, setInjectedItems, setInjectedTitle }),
|
|
188
|
+
[injectedItems, injectedTitle]
|
|
189
|
+
)
|
|
190
|
+
|
|
145
191
|
return (
|
|
146
|
-
<
|
|
147
|
-
<
|
|
148
|
-
<
|
|
149
|
-
|
|
150
|
-
{
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
192
|
+
<SidebarNavigationContext.Provider value={navContextValue}>
|
|
193
|
+
<SidebarContext.Provider value={contextValue}>
|
|
194
|
+
<TooltipProvider delayDuration={0}>
|
|
195
|
+
<div
|
|
196
|
+
style={
|
|
197
|
+
{
|
|
198
|
+
"--sidebar-width": SIDEBAR_WIDTH,
|
|
199
|
+
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
|
200
|
+
...style,
|
|
201
|
+
} as React.CSSProperties
|
|
202
|
+
}
|
|
203
|
+
suppressHydrationWarning
|
|
204
|
+
className={cn(
|
|
205
|
+
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
|
|
206
|
+
className
|
|
207
|
+
)}
|
|
208
|
+
ref={ref}
|
|
209
|
+
{...props}
|
|
210
|
+
>
|
|
211
|
+
{children}
|
|
212
|
+
</div>
|
|
213
|
+
</TooltipProvider>
|
|
214
|
+
</SidebarContext.Provider>
|
|
215
|
+
</SidebarNavigationContext.Provider>
|
|
168
216
|
)
|
|
169
217
|
}
|
|
170
218
|
)
|
|
171
219
|
SidebarProvider.displayName = "SidebarProvider"
|
|
172
220
|
|
|
173
221
|
|
|
222
|
+
const SKELETON_WIDTHS = ["68%", "85%", "58%", "92%", "74%"]
|
|
223
|
+
|
|
174
224
|
interface SidebarProps {
|
|
175
225
|
currentMenu?: string
|
|
176
226
|
onMainMenuToggle?: () => void
|
|
@@ -187,17 +237,19 @@ function Sidebar({ currentMenu, onMainMenuToggle, sidebarMenus = {}, main_base_u
|
|
|
187
237
|
const pathname = usePathname()
|
|
188
238
|
const searchParams = useSearchParams()
|
|
189
239
|
const { state, toggleSidebar } = useSidebar()
|
|
240
|
+
const { injectedItems, injectedTitle } = useSidebarNavigation()
|
|
190
241
|
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
242
|
+
const isInjecting = injectedItems !== null // null = default nav, undefined/array = detail page mounted
|
|
243
|
+
const currentNavigation: SubMenuItem[] = isInjecting
|
|
244
|
+
? (injectedItems ?? [])
|
|
245
|
+
: (sidebarMenus['items'] ?? (currentMenu && sidebarMenus[currentMenu]) ?? [])
|
|
194
246
|
|
|
195
247
|
const isCollapsed = state === "collapsed"
|
|
196
248
|
const filteredMainItems = React.useMemo(() => {
|
|
197
|
-
if (!isCollapsed || mainMenuItems.length === 0) return []
|
|
249
|
+
if (!isCollapsed || mainMenuItems.length === 0 || isInjecting) return []
|
|
198
250
|
const navIds = new Set(currentNavigation.map((n) => n.id))
|
|
199
251
|
return mainMenuItems.filter((item) => item.id !== currentMenu && !navIds.has(item.id) && !!item.href)
|
|
200
|
-
}, [isCollapsed, mainMenuItems, currentNavigation, currentMenu])
|
|
252
|
+
}, [isCollapsed, mainMenuItems, currentNavigation, currentMenu, isInjecting])
|
|
201
253
|
|
|
202
254
|
return (
|
|
203
255
|
<div
|
|
@@ -232,12 +284,18 @@ function Sidebar({ currentMenu, onMainMenuToggle, sidebarMenus = {}, main_base_u
|
|
|
232
284
|
</div>
|
|
233
285
|
|
|
234
286
|
{/* Section label — hidden when collapsed */}
|
|
235
|
-
{!isCollapsed &&
|
|
236
|
-
|
|
237
|
-
<
|
|
238
|
-
|
|
239
|
-
</
|
|
240
|
-
|
|
287
|
+
{!isCollapsed && (
|
|
288
|
+
injectedItems === undefined ? (
|
|
289
|
+
<div className="h-10 flex items-center px-4 flex-shrink-0">
|
|
290
|
+
<div className="h-2.5 w-20 bg-gray-200 animate-pulse rounded" />
|
|
291
|
+
</div>
|
|
292
|
+
) : (injectedTitle || (currentMenu && sectionLabels[currentMenu])) ? (
|
|
293
|
+
<div className="h-10 flex items-center px-4 flex-shrink-0">
|
|
294
|
+
<h2 className="text-xs font-semibold text-text-secondary uppercase tracking-wide truncate">
|
|
295
|
+
{injectedTitle || sectionLabels[currentMenu!]}
|
|
296
|
+
</h2>
|
|
297
|
+
</div>
|
|
298
|
+
) : null
|
|
241
299
|
)}
|
|
242
300
|
|
|
243
301
|
{/* Nav */}
|
|
@@ -245,9 +303,24 @@ function Sidebar({ currentMenu, onMainMenuToggle, sidebarMenus = {}, main_base_u
|
|
|
245
303
|
"flex-1 overflow-y-auto",
|
|
246
304
|
isCollapsed ? "px-1 py-3 space-y-0.5" : "px-3 py-4 space-y-1"
|
|
247
305
|
)}>
|
|
248
|
-
{/* Secondary nav items */}
|
|
249
|
-
{
|
|
250
|
-
|
|
306
|
+
{/* Secondary nav items — skeleton while injected but loading */}
|
|
307
|
+
{injectedItems === undefined ? (
|
|
308
|
+
SKELETON_WIDTHS.map((w, i) => (
|
|
309
|
+
<div
|
|
310
|
+
key={i}
|
|
311
|
+
className={cn(
|
|
312
|
+
"flex items-center border-l-4 border-transparent animate-pulse",
|
|
313
|
+
isCollapsed ? "p-2 justify-center" : "px-3 py-2 gap-x-3"
|
|
314
|
+
)}
|
|
315
|
+
>
|
|
316
|
+
<div className="h-4 w-4 bg-gray-200 flex-shrink-0" />
|
|
317
|
+
{!isCollapsed && <div className="h-3 bg-gray-200 rounded" style={{ width: w }} />}
|
|
318
|
+
</div>
|
|
319
|
+
))
|
|
320
|
+
) : currentNavigation.map((item) => {
|
|
321
|
+
const activeItemId = !injectedItems && getCurrentMenuItem
|
|
322
|
+
? getCurrentMenuItem(pathname, searchParams)
|
|
323
|
+
: null
|
|
251
324
|
const isActive = activeItemId
|
|
252
325
|
? item.id === activeItemId
|
|
253
326
|
: (pathname === item.href || pathname.startsWith(`${item.href}/`))
|
|
@@ -277,7 +350,14 @@ function Sidebar({ currentMenu, onMainMenuToggle, sidebarMenus = {}, main_base_u
|
|
|
277
350
|
className="h-4 w-4 flex-shrink-0"
|
|
278
351
|
style={{ color: isActive ? '#0f62fe' : '#4589ff' }}
|
|
279
352
|
/>
|
|
280
|
-
{!isCollapsed &&
|
|
353
|
+
{!isCollapsed && (
|
|
354
|
+
<>
|
|
355
|
+
<span className="flex-1 truncate">{item.name}</span>
|
|
356
|
+
{item.applied && (
|
|
357
|
+
<span className="h-1.5 w-1.5 rounded-full bg-green-500 flex-shrink-0" />
|
|
358
|
+
)}
|
|
359
|
+
</>
|
|
360
|
+
)}
|
|
281
361
|
</Link>
|
|
282
362
|
)
|
|
283
363
|
|
|
@@ -817,6 +897,8 @@ const SidebarMenuSubButton = React.forwardRef<
|
|
|
817
897
|
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
|
|
818
898
|
|
|
819
899
|
export {
|
|
900
|
+
useSidebarNavigation,
|
|
901
|
+
useInjectSidebarNavigation,
|
|
820
902
|
Sidebar,
|
|
821
903
|
SidebarContent,
|
|
822
904
|
SidebarFooter,
|