@orsetra/shared-ui 1.5.31 → 1.6.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.
@@ -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 {
@@ -5,7 +5,7 @@ import { Slot } from "@radix-ui/react-slot"
5
5
  import { VariantProps, cva } from "class-variance-authority"
6
6
  import { PanelLeft, ChevronLeft, ChevronRight } from "lucide-react"
7
7
  import Link from "next/link"
8
- import { usePathname, useSearchParams } from "next/navigation"
8
+ import { usePathname, useSearchParams, useRouter } from "next/navigation"
9
9
 
10
10
  import { useIsMobile } from "../../../hooks/use-mobile"
11
11
  import { cn } from "../../../lib/utils"
@@ -23,6 +23,51 @@ 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
+ injectedBackHref: string | null
32
+ setInjectedItems: React.Dispatch<React.SetStateAction<SubMenuItem[] | null | undefined>>
33
+ setInjectedTitle: React.Dispatch<React.SetStateAction<string | null>>
34
+ setInjectedBackHref: React.Dispatch<React.SetStateAction<string | null>>
35
+ }
36
+
37
+ const SidebarNavigationContext = React.createContext<SidebarNavigationContextValue>({
38
+ injectedItems: null,
39
+ injectedTitle: null,
40
+ injectedBackHref: null,
41
+ setInjectedItems: () => {},
42
+ setInjectedTitle: () => {},
43
+ setInjectedBackHref: () => {},
44
+ })
45
+
46
+ export function useSidebarNavigation() {
47
+ return React.useContext(SidebarNavigationContext)
48
+ }
49
+
50
+ /**
51
+ * Call inside a detail page/layout to replace the sidebar's secondary nav
52
+ * (and optionally its section title) for as long as the component is mounted.
53
+ * `items` must be referentially stable (useMemo / defined outside render).
54
+ */
55
+ export function useInjectSidebarNavigation(items: SubMenuItem[] | undefined, title?: string, backHref?: string) {
56
+ const { setInjectedItems, setInjectedTitle, setInjectedBackHref } = useSidebarNavigation()
57
+ React.useEffect(() => {
58
+ setInjectedItems(items)
59
+ setInjectedTitle(title ?? null)
60
+ setInjectedBackHref(backHref ?? null)
61
+ return () => {
62
+ setInjectedItems(null)
63
+ setInjectedTitle(null)
64
+ setInjectedBackHref(null)
65
+ }
66
+ }, [items, title, backHref]) // eslint-disable-line react-hooks/exhaustive-deps
67
+ }
68
+
69
+ // ── Sidebar storage ───────────────────────────────────────────────────────────
70
+
26
71
  const SIDEBAR_STORAGE_KEY = "sidebar:state"
27
72
  const SIDEBAR_WIDTH = "14rem"
28
73
  const SIDEBAR_WIDTH_ICON = "3rem"
@@ -142,35 +187,47 @@ const SidebarProvider = React.forwardRef<
142
187
  [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
143
188
  )
144
189
 
190
+ const [injectedItems, setInjectedItems] = React.useState<SubMenuItem[] | null | undefined>(null)
191
+ const [injectedTitle, setInjectedTitle] = React.useState<string | null>(null)
192
+ const [injectedBackHref, setInjectedBackHref] = React.useState<string | null>(null)
193
+ const navContextValue = React.useMemo<SidebarNavigationContextValue>(
194
+ () => ({ injectedItems, injectedTitle, injectedBackHref, setInjectedItems, setInjectedTitle, setInjectedBackHref }),
195
+ [injectedItems, injectedTitle, injectedBackHref]
196
+ )
197
+
145
198
  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>
199
+ <SidebarNavigationContext.Provider value={navContextValue}>
200
+ <SidebarContext.Provider value={contextValue}>
201
+ <TooltipProvider delayDuration={0}>
202
+ <div
203
+ style={
204
+ {
205
+ "--sidebar-width": SIDEBAR_WIDTH,
206
+ "--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
207
+ ...style,
208
+ } as React.CSSProperties
209
+ }
210
+ suppressHydrationWarning
211
+ className={cn(
212
+ "group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
213
+ className
214
+ )}
215
+ ref={ref}
216
+ {...props}
217
+ >
218
+ {children}
219
+ </div>
220
+ </TooltipProvider>
221
+ </SidebarContext.Provider>
222
+ </SidebarNavigationContext.Provider>
168
223
  )
169
224
  }
170
225
  )
171
226
  SidebarProvider.displayName = "SidebarProvider"
172
227
 
173
228
 
229
+ const SKELETON_WIDTHS = ["68%", "85%", "58%", "92%", "74%"]
230
+
174
231
  interface SidebarProps {
175
232
  currentMenu?: string
176
233
  onMainMenuToggle?: () => void
@@ -184,20 +241,29 @@ interface SidebarProps {
184
241
 
185
242
 
186
243
  function Sidebar({ currentMenu, onMainMenuToggle, sidebarMenus = {}, main_base_url = "", sectionLabels = {}, getCurrentMenuItem, mainMenuItems = [] }: SidebarProps = {}) {
187
- const pathname = usePathname()
244
+ const pathname = usePathname()
188
245
  const searchParams = useSearchParams()
246
+ const router = useRouter()
189
247
  const { state, toggleSidebar } = useSidebar()
248
+ const { injectedItems, injectedTitle, injectedBackHref } = useSidebarNavigation()
190
249
 
191
- const currentNavigation: SubMenuItem[] = sidebarMenus['items']
192
- || (currentMenu && sidebarMenus[currentMenu])
193
- || []
250
+ const isInjecting = injectedItems !== null // null = default nav, undefined/array = detail page mounted
251
+ const currentNavigation: SubMenuItem[] = isInjecting
252
+ ? (injectedItems ?? [])
253
+ : (sidebarMenus['items'] ?? (currentMenu && sidebarMenus[currentMenu]) ?? [])
194
254
 
195
255
  const isCollapsed = state === "collapsed"
256
+
257
+ const currentMainMenuItem = React.useMemo(
258
+ () => mainMenuItems.find(m => m.id === currentMenu),
259
+ [mainMenuItems, currentMenu]
260
+ )
261
+
196
262
  const filteredMainItems = React.useMemo(() => {
197
- if (!isCollapsed || mainMenuItems.length === 0) return []
263
+ if (!isCollapsed || mainMenuItems.length === 0 || isInjecting) return []
198
264
  const navIds = new Set(currentNavigation.map((n) => n.id))
199
265
  return mainMenuItems.filter((item) => item.id !== currentMenu && !navIds.has(item.id) && !!item.href)
200
- }, [isCollapsed, mainMenuItems, currentNavigation, currentMenu])
266
+ }, [isCollapsed, mainMenuItems, currentNavigation, currentMenu, isInjecting])
201
267
 
202
268
  return (
203
269
  <div
@@ -232,12 +298,38 @@ function Sidebar({ currentMenu, onMainMenuToggle, sidebarMenus = {}, main_base_u
232
298
  </div>
233
299
 
234
300
  {/* 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>
301
+ {!isCollapsed && (
302
+ injectedItems === undefined ? (
303
+ <div className="h-10 flex items-center px-4 flex-shrink-0 gap-2 border-b border-ui-border">
304
+ <div className="h-4 w-4 bg-gray-200 animate-pulse rounded flex-shrink-0" />
305
+ <div className="h-2.5 w-20 bg-gray-200 animate-pulse rounded" />
306
+ </div>
307
+ ) : (injectedTitle || (currentMenu && sectionLabels[currentMenu])) ? (
308
+ <div className="h-10 flex items-center px-4 flex-shrink-0 gap-2 border-b border-ui-border">
309
+ {isInjecting ? (
310
+ <button
311
+ type="button"
312
+ onClick={() => injectedBackHref ? router.push(injectedBackHref) : router.back()}
313
+ className="h-6 w-6 flex items-center justify-center text-text-secondary hover:text-text-primary hover:bg-ui-background rounded transition-colors flex-shrink-0"
314
+ title="Back"
315
+ >
316
+ <ChevronLeft className="h-3.5 w-3.5" />
317
+ </button>
318
+ ) : currentMainMenuItem ? (
319
+ <button
320
+ type="button"
321
+ onClick={() => router.push(currentMainMenuItem.href)}
322
+ className="h-6 w-6 flex items-center justify-center text-text-secondary hover:text-text-primary hover:bg-ui-background rounded transition-colors flex-shrink-0"
323
+ title={currentMainMenuItem.label}
324
+ >
325
+ <currentMainMenuItem.icon className="h-3.5 w-3.5" />
326
+ </button>
327
+ ) : null}
328
+ <h2 className="text-xs font-semibold text-text-secondary capitalize tracking-wide truncate flex-1">
329
+ {injectedTitle || sectionLabels[currentMenu!]}
330
+ </h2>
331
+ </div>
332
+ ) : null
241
333
  )}
242
334
 
243
335
  {/* Nav */}
@@ -245,12 +337,28 @@ function Sidebar({ currentMenu, onMainMenuToggle, sidebarMenus = {}, main_base_u
245
337
  "flex-1 overflow-y-auto",
246
338
  isCollapsed ? "px-1 py-3 space-y-0.5" : "px-3 py-4 space-y-1"
247
339
  )}>
248
- {/* Secondary nav items */}
249
- {currentNavigation.map((item) => {
250
- const activeItemId = getCurrentMenuItem ? getCurrentMenuItem(pathname, searchParams) : null
340
+ {/* Secondary nav items — skeleton while injected but loading */}
341
+ {injectedItems === undefined ? (
342
+ SKELETON_WIDTHS.map((w, i) => (
343
+ <div
344
+ key={i}
345
+ className={cn(
346
+ "flex items-center border-l-4 border-transparent animate-pulse",
347
+ isCollapsed ? "p-2 justify-center" : "px-3 py-2 gap-x-3"
348
+ )}
349
+ >
350
+ <div className="h-4 w-4 bg-gray-200 flex-shrink-0" />
351
+ {!isCollapsed && <div className="h-3 bg-gray-200 rounded" style={{ width: w }} />}
352
+ </div>
353
+ ))
354
+ ) : currentNavigation.map((item) => {
355
+ const activeItemId = !isInjecting && getCurrentMenuItem
356
+ ? getCurrentMenuItem(pathname, searchParams)
357
+ : null
358
+ const itemPath = item.href.split('?')[0]
251
359
  const isActive = activeItemId
252
360
  ? item.id === activeItemId
253
- : (pathname === item.href || pathname.startsWith(`${item.href}/`))
361
+ : (pathname === itemPath || pathname.startsWith(`${itemPath}/`))
254
362
 
255
363
  const handleClick = (e: React.MouseEvent) => {
256
364
  const href = item.href
@@ -277,7 +385,9 @@ function Sidebar({ currentMenu, onMainMenuToggle, sidebarMenus = {}, main_base_u
277
385
  className="h-4 w-4 flex-shrink-0"
278
386
  style={{ color: isActive ? '#0f62fe' : '#4589ff' }}
279
387
  />
280
- {!isCollapsed && item.name}
388
+ {!isCollapsed && (
389
+ <span className="flex-1 truncate">{item.name}</span>
390
+ )}
281
391
  </Link>
282
392
  )
283
393
 
@@ -295,7 +405,7 @@ function Sidebar({ currentMenu, onMainMenuToggle, sidebarMenus = {}, main_base_u
295
405
  {/* Main menu items — only in collapsed mode, below secondary items, neutral color */}
296
406
  {filteredMainItems.length > 0 && (
297
407
  <>
298
- <div className="border-t border-ui-border mx-1 my-2" />
408
+ <div className="border-b border-ui-border mx-1 my-2" />
299
409
  {filteredMainItems.map((item) => {
300
410
  const handleClick = (e: React.MouseEvent) => {
301
411
  const href = item.href ?? ""
@@ -817,6 +927,8 @@ const SidebarMenuSubButton = React.forwardRef<
817
927
  SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
818
928
 
819
929
  export {
930
+ useSidebarNavigation,
931
+ useInjectSidebarNavigation,
820
932
  Sidebar,
821
933
  SidebarContent,
822
934
  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.1",
4
4
  "description": "Shared UI components for Orsetra platform",
5
5
  "main": "./index.ts",
6
6
  "types": "./index.ts",