@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
- // This is the internal state of the sidebar.
76
- // We use openProp and setOpenProp for control from outside the component.
77
- // Initialize from defaultOpen (SSR-safe); sync from localStorage after mount.
78
- const [_open, _setOpen] = React.useState(defaultOpen)
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) => item.id !== currentMenu && !navIds.has(item.id) && !!item.href)
203
- }, [isCollapsed, mainMenuItems, currentNavigation, currentMenu])
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, neutral color */}
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 handleClick = (e: React.MouseEvent) => {
304
- const href = item.href
305
- if (href.startsWith('http://') || href.startsWith('https://')) {
306
- e.preventDefault()
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
- key={item.id}
313
- href={ item.href}
314
- className="flex items-center justify-center p-2 transition-colors border-l-4 border-transparent hover:bg-ui-background"
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
- <Tooltip key={item.id}>
324
- <TooltipTrigger asChild>{linkEl}</TooltipTrigger>
325
- <TooltipContent side="right" align="center">{item.label}</TooltipContent>
326
- </Tooltip>
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
- {backHref && backLabel && (
70
- <Link
71
- href={backHref}
72
- className="inline-flex items-center gap-1.5 text-xs text-ibm-gray-50 hover:text-ibm-blue-60 transition-colors mb-3"
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>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orsetra/shared-ui",
3
- "version": "1.5.25",
3
+ "version": "1.5.27",
4
4
  "description": "Shared UI components for Orsetra platform",
5
5
  "main": "./index.ts",
6
6
  "types": "./index.ts",