@orsetra/shared-ui 1.4.1 → 1.5.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.
@@ -122,7 +122,7 @@ function LayoutContent({
122
122
  />
123
123
 
124
124
  <div className="flex-1 flex flex-col min-w-0">
125
- <header className="h-14 bg-gray-50 border-b border-ui-border flex-shrink-0">
125
+ <header className="h-14 bg-gray-50 flex-shrink-0">
126
126
  <div className="h-full px-4 md:px-6 flex items-center justify-between">
127
127
  {isHidden ? (
128
128
  <Button
@@ -1,7 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import { ReactNode, useState } from "react"
4
- import { X, ChevronLeft } from "lucide-react"
4
+ import { X, ChevronLeft, SlidersHorizontal } from "lucide-react"
5
5
  import { Button } from "../ui/button"
6
6
  import { cn } from "../../lib/utils"
7
7
 
@@ -20,6 +20,9 @@ interface PageWithSidePanelProps {
20
20
  contentHeaderClassName?: string
21
21
  sidePanelClassName?: string
22
22
  sidePanelHeaderClassName?: string
23
+ /** Label for the mobile floating toggle button. When provided, a button
24
+ * appears on small screens (<lg) that opens the side panel as an overlay. */
25
+ mobileToggleLabel?: string
23
26
  }
24
27
 
25
28
  const PANEL_WIDTHS = {
@@ -43,8 +46,10 @@ export function PageWithSidePanel({
43
46
  contentHeaderClassName,
44
47
  sidePanelClassName,
45
48
  sidePanelHeaderClassName,
49
+ mobileToggleLabel,
46
50
  }: PageWithSidePanelProps) {
47
51
  const [isOpen, setIsOpen] = useState(defaultOpen)
52
+ const [mobileOpen, setMobileOpen] = useState(false)
48
53
  const panelWidthClass = PANEL_WIDTHS[sidePanelWidth]
49
54
 
50
55
  const handleClose = () => {
@@ -131,8 +136,44 @@ export function PageWithSidePanel({
131
136
  <ChevronLeft className="h-4 w-4 text-ibm-gray-70" />
132
137
  </button>
133
138
  )}
139
+
140
+ {/* Mobile overlay — only rendered below lg breakpoint */}
141
+ {mobileOpen && (
142
+ <div className="lg:hidden fixed inset-0 z-50 flex">
143
+ <div className="absolute inset-0 bg-black/40" onClick={() => setMobileOpen(false)} />
144
+ <div className="relative ml-auto flex flex-col bg-white shadow-xl h-full w-80 max-w-[90vw]">
145
+ <div className={cn(
146
+ "flex items-center justify-between h-14 flex-shrink-0 px-4 border-b border-ibm-gray-20",
147
+ sidePanelHeaderClassName
148
+ )}>
149
+ <div className="flex-1 min-w-0">{sidePanelHeader}</div>
150
+ <Button
151
+ variant="ghost"
152
+ size="xs"
153
+ onClick={() => setMobileOpen(false)}
154
+ className="h-8 w-8 p-0 ml-2 flex-shrink-0"
155
+ >
156
+ <X className="h-4 w-4" />
157
+ </Button>
158
+ </div>
159
+ <div className="flex-1 overflow-y-auto">{sidePanel}</div>
160
+ </div>
161
+ </div>
162
+ )}
134
163
  </>
135
164
  )}
165
+
166
+ {/* Mobile floating toggle button */}
167
+ {sidePanel && mobileToggleLabel && (
168
+ <button
169
+ onClick={() => setMobileOpen(true)}
170
+ className="lg:hidden fixed bottom-5 right-4 z-40 flex items-center gap-2 px-4 py-2.5 bg-ibm-blue-60 text-white text-sm font-medium shadow-lg hover:bg-ibm-blue-70 transition-colors"
171
+ aria-label={mobileToggleLabel}
172
+ >
173
+ <SlidersHorizontal className="h-4 w-4" />
174
+ {mobileToggleLabel}
175
+ </button>
176
+ )}
136
177
  </div>
137
178
  )
138
179
  }
@@ -6,7 +6,7 @@ import { cn } from "../../../lib/utils"
6
6
  import { Button } from "../../ui/button"
7
7
  import { Logo } from "../../ui/logo"
8
8
  import { type MainMenuItem, type SidebarMenus } from "./data"
9
- import { X, Menu } from "lucide-react"
9
+ import { X, Menu, ChevronDown, ChevronRight } from "lucide-react"
10
10
 
11
11
  export type SidebarMode = 'expanded' | 'minimized' | 'hidden'
12
12
 
@@ -34,27 +34,23 @@ export function MainSidebar({
34
34
  mainMenuItems = [],
35
35
  }: MainSidebarProps) {
36
36
  const isMinimized = mode === 'minimized'
37
- const [hoveredMenu, setHoveredMenu] = React.useState<string | null>(null)
38
- const [flyoutPos, setFlyoutPos] = React.useState<{ top: number; left: number } | null>(null)
39
- const closeTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
37
+ const [expandedMenu, setExpandedMenu] = React.useState<string | null>(null)
40
38
 
39
+ // Reset accordion when sidebar closes
41
40
  React.useEffect(() => {
42
- return () => { if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current) }
43
- }, [])
41
+ if (!isOpen) setExpandedMenu(null)
42
+ }, [isOpen])
44
43
 
45
- const handleFlyoutMouseEnter = (menuId: string, rect?: DOMRect) => {
46
- if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current)
47
- if (rect) setFlyoutPos({ top: rect.top, left: rect.right + 8 })
48
- setHoveredMenu(menuId)
49
- }
44
+ const handleMenuClick = (menuId: string) => {
45
+ const hasSubMenu = (sidebarMenus[menuId]?.length ?? 0) > 0
50
46
 
51
- const handleFlyoutMouseLeave = () => {
52
- closeTimeoutRef.current = setTimeout(() => setHoveredMenu(null), 150)
53
- }
47
+ // Accordion: expand/collapse inline for any non-minimized mode
48
+ if (!isMinimized && hasSubMenu) {
49
+ setExpandedMenu((prev) => (prev === menuId ? null : menuId))
50
+ return
51
+ }
54
52
 
55
- const handleMenuClick = (menuId: string) => {
56
53
  onMenuSelect(menuId)
57
-
58
54
  if (!isMinimized && onSecondarySidebarOpen) {
59
55
  onSecondarySidebarOpen()
60
56
  }
@@ -84,7 +80,7 @@ export function MainSidebar({
84
80
  const handleSubMenuClick = (e: React.MouseEvent, menuId: string, href: string) => {
85
81
  e.preventDefault()
86
82
  window.location.href = buildSubItemHref(menuId, href)
87
- setHoveredMenu(null)
83
+ setExpandedMenu(null)
88
84
  }
89
85
 
90
86
  return (
@@ -97,14 +93,14 @@ export function MainSidebar({
97
93
  )}
98
94
 
99
95
  <div className={cn(
100
- "fixed left-0 top-0 h-full bg-white border-r border-ui-border z-50 transform transition-all duration-300 ease-in-out",
96
+ "fixed left-0 top-0 h-full bg-white border-r border-ui-border z-50 transform transition-all duration-300 ease-in-out overflow-y-auto",
101
97
  isMinimized ? "w-16" : "w-64 shadow-xl",
102
98
  isMinimized
103
99
  ? "translate-x-0"
104
100
  : (isOpen ? "translate-x-0" : "-translate-x-full")
105
101
  )}>
106
102
  <div className={cn(
107
- "flex items-center h-16 border-b border-ui-border bg-white",
103
+ "flex items-center h-16 border-b border-ui-border bg-white sticky top-0 z-10",
108
104
  isMinimized ? "justify-center px-2" : "justify-between px-4"
109
105
  )}>
110
106
  {!isMinimized && <Logo className="text-2xl font-semibold text-text-primary" href={main_base_url} />}
@@ -122,77 +118,62 @@ export function MainSidebar({
122
118
  )}
123
119
  </div>
124
120
 
125
- <div className={cn(
126
- "overflow-y-auto h-full",
127
- isMinimized ? "p-2" : "p-3"
128
- )}>
129
- <nav className="space-y-1">
130
- {mainMenuItems.map((item) => {
131
- const Icon = item.icon
132
- const hasSubMenu = sidebarMenus[item.id]?.length > 0
133
- const isActive = currentMenu === item.id
134
- return (
135
- <div
136
- key={item.id}
137
- className="relative"
138
- onMouseEnter={(e) => hasSubMenu && handleFlyoutMouseEnter(item.id, e.currentTarget.getBoundingClientRect())}
139
- onMouseLeave={() => hasSubMenu && handleFlyoutMouseLeave()}
121
+ <nav className={cn("space-y-1", isMinimized ? "p-2" : "p-3")}>
122
+ {mainMenuItems.map((item) => {
123
+ const Icon = item.icon
124
+ const hasSubMenu = (sidebarMenus[item.id]?.length ?? 0) > 0
125
+ const isActive = currentMenu === item.id
126
+ const isExpanded = expandedMenu === item.id
127
+
128
+ return (
129
+ <div key={item.id}>
130
+ <button
131
+ onClick={() => handleMenuClick(item.id)}
132
+ className={cn(
133
+ "w-full flex items-center text-left transition-all duration-150 font-medium",
134
+ isMinimized ? "justify-center p-3" : "gap-3 px-3 py-2",
135
+ isActive
136
+ ? "bg-interactive/10 text-interactive border-l-4 border-interactive"
137
+ : "text-text-primary hover:bg-ui-background border-l-4 border-transparent"
138
+ )}
139
+ title={isMinimized ? item.label : undefined}
140
140
  >
141
- <button
142
- onClick={() => handleMenuClick(item.id)}
143
- className={cn(
144
- "w-full flex items-center text-left transition-all duration-150 font-medium",
145
- isMinimized ? "justify-center p-3" : "gap-3 px-3 py-2",
146
- isActive
147
- ? "bg-interactive/10 text-interactive border-l-4 border-interactive"
148
- : "text-text-primary hover:bg-ui-background border-l-4 border-transparent"
149
- )}
150
- title={isMinimized ? item.label : undefined}
151
- >
152
- <Icon className={cn(
153
- "h-5 w-5 flex-shrink-0",
154
- isActive ? "text-interactive" : "text-text-secondary"
155
- )} />
156
- {!isMinimized && <span className="text-base">{item.label}</span>}
157
- </button>
158
- </div>
159
- )
160
- })}
161
- </nav>
162
- </div>
163
-
164
- {/* Flyout en position fixed : échappe overflow-y-auto et tout contexte d'empilement */}
165
- {hoveredMenu && flyoutPos && sidebarMenus[hoveredMenu]?.length > 0 && (() => {
166
- const activeItem = mainMenuItems.find(i => i.id === hoveredMenu)
167
- if (!activeItem) return null
168
- return (
169
- <div
170
- className="fixed bg-white border border-ui-border shadow-lg z-[9999] min-w-[220px] py-1"
171
- style={{ top: flyoutPos.top, left: flyoutPos.left }}
172
- onMouseEnter={() => handleFlyoutMouseEnter(hoveredMenu)}
173
- onMouseLeave={handleFlyoutMouseLeave}
174
- >
175
- {/* Arrow pointing left */}
176
- <div className="absolute -left-[5px] top-[14px] w-[10px] h-[10px] bg-white border-l border-t border-ui-border -rotate-45" />
177
- <div className="px-4 py-2 border-b border-ui-border">
178
- <p className="text-xs font-semibold text-text-primary uppercase tracking-wide">{activeItem.label}</p>
179
- </div>
180
- <div>
181
- {sidebarMenus[hoveredMenu].map((subItem) => (
182
- <a
183
- key={subItem.id}
184
- href={buildSubItemHref(hoveredMenu, subItem.href)}
185
- onClick={(e) => handleSubMenuClick(e, hoveredMenu, subItem.href)}
186
- className="flex items-center gap-3 px-4 py-2 text-sm text-text-secondary hover:bg-ui-background hover:text-text-primary transition-colors no-underline border-l-4 border-transparent hover:border-interactive"
187
- >
188
- <subItem.icon className="h-4 w-4 flex-shrink-0 text-text-secondary" />
189
- {subItem.name}
190
- </a>
191
- ))}
141
+ <Icon className={cn(
142
+ "h-5 w-5 flex-shrink-0",
143
+ isActive ? "text-interactive" : "text-text-secondary"
144
+ )} />
145
+ {!isMinimized && (
146
+ <>
147
+ <span className="text-base flex-1">{item.label}</span>
148
+ {hasSubMenu && (
149
+ isExpanded
150
+ ? <ChevronDown className="h-4 w-4 text-text-secondary flex-shrink-0" />
151
+ : <ChevronRight className="h-4 w-4 text-text-secondary flex-shrink-0" />
152
+ )}
153
+ </>
154
+ )}
155
+ </button>
156
+
157
+ {/* Inline accordion sub-items */}
158
+ {!isMinimized && isExpanded && hasSubMenu && (
159
+ <div className="pl-4 pb-1">
160
+ {sidebarMenus[item.id].map((subItem) => (
161
+ <a
162
+ key={subItem.id}
163
+ href={buildSubItemHref(item.id, subItem.href)}
164
+ onClick={(e) => handleSubMenuClick(e, item.id, subItem.href)}
165
+ className="flex items-center gap-3 px-3 py-2 text-sm text-text-secondary hover:bg-ui-background hover:text-text-primary transition-colors no-underline border-l-2 border-ui-border hover:border-interactive"
166
+ >
167
+ <subItem.icon className="h-4 w-4 flex-shrink-0 text-text-secondary" />
168
+ {subItem.name}
169
+ </a>
170
+ ))}
171
+ </div>
172
+ )}
192
173
  </div>
193
- </div>
194
- )
195
- })()}
174
+ )
175
+ })}
176
+ </nav>
196
177
  </div>
197
178
  </>
198
179
  )
@@ -3,7 +3,7 @@
3
3
  import * as React from "react"
4
4
  import { Slot } from "@radix-ui/react-slot"
5
5
  import { VariantProps, cva } from "class-variance-authority"
6
- import { PanelLeft } from "lucide-react"
6
+ import { PanelLeft, ChevronLeft, ChevronRight } from "lucide-react"
7
7
  import Link from "next/link"
8
8
  import { usePathname, useSearchParams } from "next/navigation"
9
9
 
@@ -23,8 +23,7 @@ import { Logo } from "../../ui/logo"
23
23
  import { type SidebarMenus, type SubMenuItem } from "./data"
24
24
  import { Skeleton } from "../skeleton"
25
25
 
26
- const SIDEBAR_COOKIE_NAME = "sidebar:state"
27
- const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
26
+ const SIDEBAR_STORAGE_KEY = "sidebar:state"
28
27
  const SIDEBAR_WIDTH = "14rem"
29
28
  const SIDEBAR_WIDTH_ICON = "3rem"
30
29
  const SIDEBAR_KEYBOARD_SHORTCUT = "b"
@@ -75,8 +74,19 @@ const SidebarProvider = React.forwardRef<
75
74
 
76
75
  // This is the internal state of the sidebar.
77
76
  // We use openProp and setOpenProp for control from outside the component.
77
+ // Initialize from defaultOpen (SSR-safe); sync from localStorage after mount.
78
78
  const [_open, _setOpen] = React.useState(defaultOpen)
79
79
  const open = openProp ?? _open
80
+
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
+
80
90
  const setOpen = React.useCallback(
81
91
  (value: boolean | ((value: boolean) => boolean)) => {
82
92
  const openState = typeof value === "function" ? value(open) : value
@@ -86,8 +96,10 @@ const SidebarProvider = React.forwardRef<
86
96
  _setOpen(openState)
87
97
  }
88
98
 
89
- // This sets the cookie to keep the sidebar state.
90
- document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
99
+ // Persist state so it survives client-side navigation and page reloads.
100
+ try {
101
+ localStorage.setItem(SIDEBAR_STORAGE_KEY, String(openState))
102
+ } catch {}
91
103
  },
92
104
  [setOpenProp, open]
93
105
  )
@@ -174,76 +186,113 @@ interface SidebarProps {
174
186
  function Sidebar({ currentMenu, onMainMenuToggle, sidebarMenus = {}, main_base_url = "", sectionLabels = {}, getCurrentMenuItem }: SidebarProps = {}) {
175
187
  const pathname = usePathname()
176
188
  const searchParams = useSearchParams()
177
- const { state } = useSidebar()
189
+ const { state, toggleSidebar } = useSidebar()
178
190
 
179
- // Micro apps: utilise 'items' comme clé standard
180
- // Main app: utilise currentMenu pour sélectionner le bon groupe
181
191
  const currentNavigation: SubMenuItem[] = sidebarMenus['items']
182
192
  || (currentMenu && sidebarMenus[currentMenu])
183
193
  || []
184
194
 
195
+ const isCollapsed = state === "collapsed"
196
+
185
197
  return (
186
- <div className="h-screen sticky top-0 flex flex-col bg-gray-50 border-r border-ui-border min-w-[var(--sidebar-width-icon)] transition-[width] duration-200 overflow-visible"
187
- style={{ width: state === "expanded" ? "var(--sidebar-width)" : "var(--sidebar-width-icon)" }}>
188
- {/* Logo avec bouton de menu principal */}
189
- <div className="h-14 flex items-center justify-between px-4 border-b border-ui-border">
190
- <Logo />
191
- {onMainMenuToggle && (
192
- <Button
193
- variant="ghost"
194
- size="sm"
195
- onClick={onMainMenuToggle}
196
- className="h-8 w-8 p-0 hover:bg-ibm-gray-20 rounded-lg"
197
- title="Ouvrir le menu principal"
198
- >
199
- <Menu className="h-4 w-4" />
200
- </Button>
198
+ <div
199
+ className="h-screen sticky top-0 flex flex-col bg-gray-50 min-w-[var(--sidebar-width-icon)] transition-[width] duration-200 overflow-visible"
200
+ style={{ width: isCollapsed ? "var(--sidebar-width-icon)" : "var(--sidebar-width)" }}
201
+ >
202
+ {/* Header */}
203
+ <div className={cn(
204
+ "h-14 flex items-center flex-shrink-0",
205
+ isCollapsed ? "justify-center px-2" : "justify-between px-4"
206
+ )}>
207
+ {isCollapsed ? (
208
+ <Logo iconOnly />
209
+ ) : (
210
+ <>
211
+ <Logo />
212
+ {onMainMenuToggle && (
213
+ <Button
214
+ variant="ghost"
215
+ size="sm"
216
+ onClick={onMainMenuToggle}
217
+ className="h-8 w-8 p-0 hover:bg-ui-background rounded-lg"
218
+ title="Ouvrir le menu principal"
219
+ >
220
+ <Menu className="h-4 w-4" />
221
+ </Button>
222
+ )}
223
+ </>
201
224
  )}
202
225
  </div>
203
226
 
204
- {/* En-tête du menu principal sélectionné */}
205
- {currentMenu && sectionLabels[currentMenu] && (
206
- <div className="h-14 flex items-center px-4 border-b border-ui-border">
207
- <h2 className="text-sm font-semibold text-text-primary">
227
+ {/* Section label hidden when collapsed */}
228
+ {!isCollapsed && currentMenu && sectionLabels[currentMenu] && (
229
+ <div className="h-10 flex items-center px-4 flex-shrink-0">
230
+ <h2 className="text-xs font-semibold text-text-secondary uppercase tracking-wide">
208
231
  {sectionLabels[currentMenu]}
209
232
  </h2>
210
233
  </div>
211
234
  )}
212
235
 
213
- <nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
236
+ {/* Nav */}
237
+ <nav className={cn(
238
+ "flex-1 overflow-y-auto",
239
+ isCollapsed ? "px-1 py-3 space-y-0.5" : "px-3 py-4 space-y-1"
240
+ )}>
214
241
  {currentNavigation.map((item) => {
215
- // Use custom function if provided, otherwise use default pathname matching
216
242
  const activeItemId = getCurrentMenuItem ? getCurrentMenuItem(pathname, searchParams) : null
217
- const isActive = activeItemId ? item.id === activeItemId : (pathname === item.href || pathname.startsWith(`${item.href}/`))
218
-
243
+ const isActive = activeItemId
244
+ ? item.id === activeItemId
245
+ : (pathname === item.href || pathname.startsWith(`${item.href}/`))
246
+
219
247
  const handleClick = (e: React.MouseEvent) => {
220
248
  const href = item.href
221
249
  if (href.startsWith('http://') || href.startsWith('https://')) {
222
250
  e.preventDefault()
223
- const path = new URL(href).pathname
224
- window.location.href = path
225
- }
251
+ window.location.href = new URL(href).pathname
252
+ }
226
253
  }
227
-
228
- return (
254
+
255
+ const linkEl = (
229
256
  <Link
230
257
  key={item.id}
231
258
  href={item.href}
232
259
  onClick={handleClick}
233
260
  className={cn(
234
- "flex items-center px-3 py-2 text-sm gap-x-3 transition-colors border-l-4",
261
+ "flex items-center transition-colors border-l-4",
262
+ isCollapsed ? "justify-center p-2" : "px-3 py-2 gap-x-3 text-sm",
235
263
  isActive
236
264
  ? "bg-interactive/10 text-interactive font-medium border-interactive"
237
265
  : "text-text-secondary hover:bg-ui-background hover:text-text-primary border-transparent"
238
266
  )}
239
267
  >
240
268
  <item.icon className={cn("h-4 w-4 flex-shrink-0", isActive ? "text-interactive" : "text-text-secondary")} />
241
- {item.name}
269
+ {!isCollapsed && item.name}
242
270
  </Link>
243
271
  )
272
+
273
+ if (isCollapsed) {
274
+ return (
275
+ <Tooltip key={item.id}>
276
+ <TooltipTrigger asChild>{linkEl}</TooltipTrigger>
277
+ <TooltipContent side="right" align="center">{item.name}</TooltipContent>
278
+ </Tooltip>
279
+ )
280
+ }
281
+ return linkEl
244
282
  })}
245
283
  </nav>
246
284
 
285
+ {/* Collapse / expand toggle */}
286
+ <div className={cn("flex-shrink-0 p-2", isCollapsed ? "flex justify-center" : "flex justify-end")}>
287
+ <button
288
+ type="button"
289
+ onClick={toggleSidebar}
290
+ className="h-8 w-8 flex items-center justify-center text-text-secondary hover:text-text-primary hover:bg-ui-background rounded-md transition-colors"
291
+ title={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
292
+ >
293
+ {isCollapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
294
+ </button>
295
+ </div>
247
296
  </div>
248
297
  )
249
298
  }
@@ -679,7 +728,7 @@ const SidebarMenuSub = React.forwardRef<
679
728
  ref={ref}
680
729
  data-sidebar="menu-sub"
681
730
  className={cn(
682
- "mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
731
+ "mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 px-2.5 py-0.5",
683
732
  "group-data-[collapsible=icon]:hidden",
684
733
  className
685
734
  )}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orsetra/shared-ui",
3
- "version": "1.4.1",
3
+ "version": "1.5.1",
4
4
  "description": "Shared UI components for Orsetra platform",
5
5
  "main": "./index.ts",
6
6
  "types": "./index.ts",