@orsetra/shared-ui 1.4.0 → 1.5.0

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
 
@@ -36,12 +36,18 @@ export function MainSidebar({
36
36
  const isMinimized = mode === 'minimized'
37
37
  const [hoveredMenu, setHoveredMenu] = React.useState<string | null>(null)
38
38
  const [flyoutPos, setFlyoutPos] = React.useState<{ top: number; left: number } | null>(null)
39
+ const [expandedMenu, setExpandedMenu] = React.useState<string | null>(null)
39
40
  const closeTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
40
41
 
41
42
  React.useEffect(() => {
42
43
  return () => { if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current) }
43
44
  }, [])
44
45
 
46
+ // Reset expanded menu when sidebar closes
47
+ React.useEffect(() => {
48
+ if (!isOpen) setExpandedMenu(null)
49
+ }, [isOpen])
50
+
45
51
  const handleFlyoutMouseEnter = (menuId: string, rect?: DOMRect) => {
46
52
  if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current)
47
53
  if (rect) setFlyoutPos({ top: rect.top, left: rect.right + 8 })
@@ -53,6 +59,14 @@ export function MainSidebar({
53
59
  }
54
60
 
55
61
  const handleMenuClick = (menuId: string) => {
62
+ const hasSubMenu = (sidebarMenus[menuId]?.length ?? 0) > 0
63
+
64
+ // In mobile overlay mode (not minimized, overlay open): expand inline instead of navigating
65
+ if (!isMinimized && isOpen && hasSubMenu) {
66
+ setExpandedMenu((prev) => (prev === menuId ? null : menuId))
67
+ return
68
+ }
69
+
56
70
  onMenuSelect(menuId)
57
71
 
58
72
  if (!isMinimized && onSecondarySidebarOpen) {
@@ -153,8 +167,30 @@ export function MainSidebar({
153
167
  "h-5 w-5 flex-shrink-0",
154
168
  isActive ? "text-interactive" : "text-text-secondary"
155
169
  )} />
156
- {!isMinimized && <span className="text-base">{item.label}</span>}
170
+ {!isMinimized && <span className="text-base flex-1">{item.label}</span>}
171
+ {!isMinimized && hasSubMenu && isOpen && (
172
+ expandedMenu === item.id
173
+ ? <ChevronDown className="h-4 w-4 text-text-secondary flex-shrink-0" />
174
+ : <ChevronRight className="h-4 w-4 text-text-secondary flex-shrink-0" />
175
+ )}
157
176
  </button>
177
+
178
+ {/* Inline accordion sub-items — shown on mobile (overlay open, not minimized) */}
179
+ {!isMinimized && isOpen && expandedMenu === item.id && hasSubMenu && (
180
+ <div className="pl-4 pb-1">
181
+ {sidebarMenus[item.id].map((subItem) => (
182
+ <a
183
+ key={subItem.id}
184
+ href={buildSubItemHref(item.id, subItem.href)}
185
+ onClick={(e) => handleSubMenuClick(e, item.id, subItem.href)}
186
+ 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"
187
+ >
188
+ <subItem.icon className="h-4 w-4 flex-shrink-0 text-text-secondary" />
189
+ {subItem.name}
190
+ </a>
191
+ ))}
192
+ </div>
193
+ )}
158
194
  </div>
159
195
  )
160
196
  })}
@@ -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
  )}
@@ -9,23 +9,20 @@ export interface SecretEntry {
9
9
  }
10
10
 
11
11
  interface SecretContextValue {
12
- templateId?: string
13
- fetchSecrets: (templateId?: string) => Promise<SecretEntry[]>
12
+ secretsMap: Map<string, SecretEntry[]>
14
13
  }
15
14
 
16
15
  const SecretContext = createContext<SecretContextValue | null>(null)
17
16
 
18
17
  export function SecretContextProvider({
19
18
  children,
20
- templateId,
21
- fetchSecrets,
19
+ secretsMap,
22
20
  }: {
23
21
  children: ReactNode
24
- templateId?: string
25
- fetchSecrets: (templateId?: string) => Promise<SecretEntry[]>
22
+ secretsMap: Map<string, SecretEntry[]>
26
23
  }) {
27
24
  return (
28
- <SecretContext.Provider value={{ templateId, fetchSecrets }}>
25
+ <SecretContext.Provider value={{ secretsMap }}>
29
26
  {children}
30
27
  </SecretContext.Provider>
31
28
  )
@@ -1,10 +1,7 @@
1
1
  "use client"
2
2
 
3
- import { useState, useEffect } from "react"
4
- import { Loader2 } from "lucide-react"
5
3
  import { SelectInput } from "./select-input"
6
4
  import { useSecretContext } from "./secret-context"
7
- import type { SecretEntry } from "./secret-context"
8
5
 
9
6
  interface SecretInputProps {
10
7
  id?: string
@@ -26,26 +23,6 @@ export function SecretInput({
26
23
  placeholder = "Select a secret",
27
24
  }: SecretInputProps) {
28
25
  const ctx = useSecretContext()
29
- const [secrets, setSecrets] = useState<SecretEntry[]>([])
30
- const [loading, setLoading] = useState(false)
31
-
32
- useEffect(() => {
33
- if (!ctx) return
34
- setLoading(true)
35
- ctx.fetchSecrets(type === "configs" ? ctx.templateId : type)
36
- .then(setSecrets)
37
- .catch(() => setSecrets([]))
38
- .finally(() => setLoading(false))
39
- }, [ctx?.templateId])
40
-
41
- if (loading) {
42
- return (
43
- <div className="flex items-center gap-2 h-10 px-3 border border-ibm-gray-30 bg-white text-sm text-ibm-gray-60">
44
- <Loader2 className="h-4 w-4 animate-spin flex-shrink-0" />
45
- Loading secrets…
46
- </div>
47
- )
48
- }
49
26
 
50
27
  if (!ctx) {
51
28
  return (
@@ -54,7 +31,7 @@ export function SecretInput({
54
31
  </div>
55
32
  )
56
33
  }
57
-
34
+ const secrets = ctx.secretsMap.get(type || 'configs') || []
58
35
  return (
59
36
  <SelectInput
60
37
  id={id}
@@ -63,7 +40,7 @@ export function SecretInput({
63
40
  disabled={disabled}
64
41
  className={className}
65
42
  placeholder={placeholder}
66
- options={secrets.map((s) => ({ label: s.description || s.name, value: s.name }))}
43
+ options={secrets.map((s) => ({ label: s.description || s.name, value: `secret:${s.name}` }))}
67
44
  />
68
45
  )
69
46
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orsetra/shared-ui",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Shared UI components for Orsetra platform",
5
5
  "main": "./index.ts",
6
6
  "types": "./index.ts",