@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: string
50
- name: string
51
- href: string
52
- icon: LucideIcon
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
- <SidebarContext.Provider value={contextValue}>
147
- <TooltipProvider delayDuration={0}>
148
- <div
149
- style={
150
- {
151
- "--sidebar-width": SIDEBAR_WIDTH,
152
- "--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
153
- ...style,
154
- } as React.CSSProperties
155
- }
156
- suppressHydrationWarning
157
- className={cn(
158
- "group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
159
- className
160
- )}
161
- ref={ref}
162
- {...props}
163
- >
164
- {children}
165
- </div>
166
- </TooltipProvider>
167
- </SidebarContext.Provider>
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 currentNavigation: SubMenuItem[] = sidebarMenus['items']
192
- || (currentMenu && sidebarMenus[currentMenu])
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 && currentMenu && sectionLabels[currentMenu] && (
236
- <div className="h-10 flex items-center px-4 flex-shrink-0">
237
- <h2 className="text-xs font-semibold text-text-secondary uppercase tracking-wide">
238
- {sectionLabels[currentMenu]}
239
- </h2>
240
- </div>
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
- {currentNavigation.map((item) => {
250
- const activeItemId = getCurrentMenuItem ? getCurrentMenuItem(pathname, searchParams) : null
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 && item.name}
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orsetra/shared-ui",
3
- "version": "1.5.31",
3
+ "version": "1.6.0",
4
4
  "description": "Shared UI components for Orsetra platform",
5
5
  "main": "./index.ts",
6
6
  "types": "./index.ts",