@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:
|
|
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 {
|
|
@@ -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
|
-
<
|
|
147
|
-
<
|
|
148
|
-
<
|
|
149
|
-
|
|
150
|
-
{
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
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
|
|
192
|
-
|
|
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 &&
|
|
236
|
-
|
|
237
|
-
<
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
{
|
|
250
|
-
|
|
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 ===
|
|
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 &&
|
|
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-
|
|
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,
|