@orsetra/shared-ui 1.5.25 → 1.5.27
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.
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import Link from "next/link"
|
|
5
|
+
import { type SubMenuItem } from "./data"
|
|
6
|
+
|
|
7
|
+
interface SidebarFlyoutProps {
|
|
8
|
+
label: string
|
|
9
|
+
subItems: SubMenuItem[]
|
|
10
|
+
onClose: () => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function SidebarFlyout({ label, subItems, onClose }: SidebarFlyoutProps) {
|
|
14
|
+
return (
|
|
15
|
+
<div
|
|
16
|
+
className="absolute left-full top-0 ml-1 w-52 bg-white shadow-lg border border-ui-border z-50"
|
|
17
|
+
onMouseEnter={(e) => e.stopPropagation()}
|
|
18
|
+
onMouseLeave={onClose}
|
|
19
|
+
>
|
|
20
|
+
<div className="px-3 py-2 text-[11px] font-semibold text-text-secondary uppercase tracking-wide border-b border-ui-border">
|
|
21
|
+
{label}
|
|
22
|
+
</div>
|
|
23
|
+
{subItems.map((subItem) => (
|
|
24
|
+
<Link
|
|
25
|
+
key={subItem.id}
|
|
26
|
+
href={subItem.href}
|
|
27
|
+
onClick={(e) => {
|
|
28
|
+
if (subItem.href.startsWith("http://") || subItem.href.startsWith("https://")) {
|
|
29
|
+
e.preventDefault()
|
|
30
|
+
window.location.href = new URL(subItem.href).pathname
|
|
31
|
+
}
|
|
32
|
+
onClose()
|
|
33
|
+
}}
|
|
34
|
+
className="flex items-center gap-2.5 px-3 py-2.5 text-sm text-text-secondary hover:bg-ui-background hover:text-text-primary transition-colors"
|
|
35
|
+
>
|
|
36
|
+
<subItem.icon className="h-4 w-4 flex-shrink-0" />
|
|
37
|
+
{subItem.name}
|
|
38
|
+
</Link>
|
|
39
|
+
))}
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
import { Menu } from "lucide-react"
|
|
22
22
|
import { Logo } from "../../ui/logo"
|
|
23
23
|
import { type SidebarMenus, type SubMenuItem, type MainMenuItem } from "./data"
|
|
24
|
+
import { SidebarFlyout } from "./sidebar-flyout"
|
|
24
25
|
import { Skeleton } from "../skeleton"
|
|
25
26
|
|
|
26
27
|
const SIDEBAR_STORAGE_KEY = "sidebar:state"
|
|
@@ -72,21 +73,19 @@ const SidebarProvider = React.forwardRef<
|
|
|
72
73
|
const isMobile = useIsMobile()
|
|
73
74
|
const [openMobile, setOpenMobile] = React.useState(false)
|
|
74
75
|
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
// Read from localStorage synchronously on first client render to avoid flash.
|
|
77
|
+
// Falls back to defaultOpen during SSR (typeof window === 'undefined').
|
|
78
|
+
const [_open, _setOpen] = React.useState(() => {
|
|
79
|
+
if (typeof window !== "undefined") {
|
|
80
|
+
try {
|
|
81
|
+
const stored = localStorage.getItem(SIDEBAR_STORAGE_KEY)
|
|
82
|
+
if (stored !== null) return stored === "true"
|
|
83
|
+
} catch {}
|
|
84
|
+
}
|
|
85
|
+
return defaultOpen
|
|
86
|
+
})
|
|
79
87
|
const open = openProp ?? _open
|
|
80
88
|
|
|
81
|
-
// Hydrate from localStorage on first client render (avoids SSR mismatch).
|
|
82
|
-
React.useEffect(() => {
|
|
83
|
-
try {
|
|
84
|
-
const stored = localStorage.getItem(SIDEBAR_STORAGE_KEY)
|
|
85
|
-
if (stored !== null) _setOpen(stored === "true")
|
|
86
|
-
} catch {}
|
|
87
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
88
|
-
}, [])
|
|
89
|
-
|
|
90
89
|
const setOpen = React.useCallback(
|
|
91
90
|
(value: boolean | ((value: boolean) => boolean)) => {
|
|
92
91
|
const openState = typeof value === "function" ? value(open) : value
|
|
@@ -155,6 +154,7 @@ const SidebarProvider = React.forwardRef<
|
|
|
155
154
|
...style,
|
|
156
155
|
} as React.CSSProperties
|
|
157
156
|
}
|
|
157
|
+
suppressHydrationWarning
|
|
158
158
|
className={cn(
|
|
159
159
|
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
|
|
160
160
|
className
|
|
@@ -194,13 +194,17 @@ function Sidebar({ currentMenu, onMainMenuToggle, sidebarMenus = {}, main_base_u
|
|
|
194
194
|
|| []
|
|
195
195
|
|
|
196
196
|
const isCollapsed = state === "collapsed"
|
|
197
|
+
const [flyoutMenu, setFlyoutMenu] = React.useState<string | null>(null)
|
|
197
198
|
|
|
198
199
|
const filteredMainItems = React.useMemo(() => {
|
|
199
200
|
if (!isCollapsed || mainMenuItems.length === 0) return []
|
|
200
|
-
// Exclude items that match the current menu context OR appear in the secondary nav OR have no href
|
|
201
201
|
const navIds = new Set(currentNavigation.map((n) => n.id))
|
|
202
|
-
return mainMenuItems.filter((item) =>
|
|
203
|
-
|
|
202
|
+
return mainMenuItems.filter((item) => {
|
|
203
|
+
if (item.id === currentMenu || navIds.has(item.id)) return false
|
|
204
|
+
const hasSubItems = (sidebarMenus[item.id]?.length ?? 0) > 0
|
|
205
|
+
return hasSubItems || !!item.href
|
|
206
|
+
})
|
|
207
|
+
}, [isCollapsed, mainMenuItems, currentNavigation, currentMenu, sidebarMenus])
|
|
204
208
|
|
|
205
209
|
return (
|
|
206
210
|
<div
|
|
@@ -295,35 +299,56 @@ function Sidebar({ currentMenu, onMainMenuToggle, sidebarMenus = {}, main_base_u
|
|
|
295
299
|
return linkEl
|
|
296
300
|
})}
|
|
297
301
|
|
|
298
|
-
{/* Main menu items — only in collapsed mode, below secondary items
|
|
302
|
+
{/* Main menu items — only in collapsed mode, below secondary items */}
|
|
299
303
|
{filteredMainItems.length > 0 && (
|
|
300
304
|
<>
|
|
301
305
|
<div className="border-t border-ui-border mx-1 my-2" />
|
|
302
306
|
{filteredMainItems.map((item) => {
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
window.location.href = new URL(href).pathname
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
const linkEl = (
|
|
307
|
+
const subItems = sidebarMenus[item.id] ?? []
|
|
308
|
+
const hasSubItems = subItems.length > 0
|
|
309
|
+
|
|
310
|
+
const iconBtn = (
|
|
311
311
|
<Link
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
312
|
+
href={hasSubItems ? "#" : (item.href ?? "#")}
|
|
313
|
+
onClick={(e) => {
|
|
314
|
+
if (hasSubItems) { e.preventDefault(); return }
|
|
315
|
+
const href = item.href ?? ""
|
|
316
|
+
if (href.startsWith("http://") || href.startsWith("https://")) {
|
|
317
|
+
e.preventDefault()
|
|
318
|
+
window.location.href = new URL(href).pathname
|
|
319
|
+
}
|
|
320
|
+
}}
|
|
321
|
+
className="flex items-center justify-center p-2 transition-colors border-l-4 border-transparent hover:bg-ui-background w-full"
|
|
315
322
|
>
|
|
316
|
-
<item.icon
|
|
317
|
-
className="h-4 w-4 flex-shrink-0"
|
|
318
|
-
style={{ color: '#8d8d8d' }}
|
|
319
|
-
/>
|
|
323
|
+
<item.icon className="h-4 w-4 flex-shrink-0" style={{ color: "#8d8d8d" }} />
|
|
320
324
|
</Link>
|
|
321
325
|
)
|
|
326
|
+
|
|
322
327
|
return (
|
|
323
|
-
<
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
328
|
+
<div
|
|
329
|
+
key={item.id}
|
|
330
|
+
className="relative"
|
|
331
|
+
onMouseEnter={() => { if (hasSubItems) setFlyoutMenu(item.id) }}
|
|
332
|
+
onMouseLeave={() => setFlyoutMenu(null)}
|
|
333
|
+
>
|
|
334
|
+
{hasSubItems ? (
|
|
335
|
+
iconBtn
|
|
336
|
+
) : (
|
|
337
|
+
<Tooltip>
|
|
338
|
+
<TooltipTrigger asChild>{iconBtn}</TooltipTrigger>
|
|
339
|
+
<TooltipContent side="right" align="center">{item.label}</TooltipContent>
|
|
340
|
+
</Tooltip>
|
|
341
|
+
)}
|
|
342
|
+
|
|
343
|
+
{/* Flyout panel */}
|
|
344
|
+
{hasSubItems && flyoutMenu === item.id && (
|
|
345
|
+
<SidebarFlyout
|
|
346
|
+
label={item.label}
|
|
347
|
+
subItems={subItems}
|
|
348
|
+
onClose={() => setFlyoutMenu(null)}
|
|
349
|
+
/>
|
|
350
|
+
)}
|
|
351
|
+
</div>
|
|
327
352
|
)
|
|
328
353
|
})}
|
|
329
354
|
</>
|
|
@@ -25,6 +25,8 @@ export interface DetailPageHeaderProps {
|
|
|
25
25
|
backHref?: string
|
|
26
26
|
/** Back breadcrumb label */
|
|
27
27
|
backLabel?: string
|
|
28
|
+
/** Callback used instead of backHref when you want router.back() behaviour */
|
|
29
|
+
backAction?: () => void
|
|
28
30
|
/** Optional icon rendered in a blue square container */
|
|
29
31
|
icon?: ReactNode
|
|
30
32
|
/** Page title – truncated to one line */
|
|
@@ -50,6 +52,7 @@ export interface DetailPageHeaderProps {
|
|
|
50
52
|
export function DetailPageHeader({
|
|
51
53
|
backHref,
|
|
52
54
|
backLabel,
|
|
55
|
+
backAction,
|
|
53
56
|
icon,
|
|
54
57
|
title,
|
|
55
58
|
titleNode,
|
|
@@ -61,16 +64,20 @@ export function DetailPageHeader({
|
|
|
61
64
|
enableTabs = true,
|
|
62
65
|
className,
|
|
63
66
|
}: DetailPageHeaderProps) {
|
|
67
|
+
const backCls = "inline-flex items-center gap-1.5 text-xs text-ibm-gray-50 hover:text-ibm-blue-60 transition-colors mb-3"
|
|
64
68
|
return (
|
|
65
69
|
<div className={cn("border-ibm-gray-20", className)}>
|
|
66
70
|
<div className="px-4 sm:px-2 pt-1 pb-0">
|
|
67
71
|
|
|
68
72
|
{/* Breadcrumb */}
|
|
69
|
-
{
|
|
70
|
-
<
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
>
|
|
73
|
+
{backAction && backLabel && (
|
|
74
|
+
<button type="button" onClick={backAction} className={backCls}>
|
|
75
|
+
<ArrowLeft className="h-3 w-3" />
|
|
76
|
+
{backLabel}
|
|
77
|
+
</button>
|
|
78
|
+
)}
|
|
79
|
+
{!backAction && backHref && backLabel && (
|
|
80
|
+
<Link href={backHref} className={backCls}>
|
|
74
81
|
<ArrowLeft className="h-3 w-3" />
|
|
75
82
|
{backLabel}
|
|
76
83
|
</Link>
|