@orsetra/shared-ui 1.7.7 → 1.8.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.
@@ -60,6 +60,7 @@ function LayoutContent({
60
60
  main.map((item) => ({
61
61
  id: item.id,
62
62
  label: item.label,
63
+ description: item.description,
63
64
  icon: resolveIcon(item.icon),
64
65
  href: item.href,
65
66
  }))
@@ -71,6 +72,7 @@ function LayoutContent({
71
72
  menus[key] = subMenu.items.map((item) => ({
72
73
  id: item.id,
73
74
  name: item.label,
75
+ description: item.description,
74
76
  href: item.href,
75
77
  icon: resolveIcon(item.icon),
76
78
  }))
@@ -41,16 +41,18 @@ import {
41
41
  export interface MainMenuItem {
42
42
  id: string
43
43
  label: string
44
+ description?: string
44
45
  icon: LucideIcon
45
46
  href: string
46
47
  }
47
48
 
48
49
  export interface SubMenuItem {
49
- id: string
50
- name: string
51
- href: string
52
- icon: LucideIcon
53
- applied?: boolean
50
+ id: string
51
+ name: string
52
+ description?: string
53
+ href: string
54
+ icon: LucideIcon
55
+ applied?: boolean
54
56
  }
55
57
 
56
58
  export interface SidebarMenus {
@@ -72,15 +74,17 @@ export interface Project {
72
74
  export interface ApiMainMenuItem {
73
75
  id: string
74
76
  label: string
77
+ description?: string
75
78
  icon: string
76
79
  href: string
77
80
  }
78
81
 
79
82
  export interface ApiSubMenuItem {
80
- id: string
81
- label: string
82
- href: string
83
- icon: string
83
+ id: string
84
+ label: string
85
+ description?: string
86
+ href: string
87
+ icon: string
84
88
  }
85
89
 
86
90
  export interface ApiSubMenu {
@@ -0,0 +1,72 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { cn } from "../../../lib/utils"
5
+ import type { MainMenuItem, SubMenuItem } from "./data"
6
+
7
+ interface MainSidebarFlyoutProps {
8
+ item: MainMenuItem
9
+ /** Sub-items with hrefs already resolved by the parent */
10
+ subItems: SubMenuItem[]
11
+ onMouseEnter: () => void
12
+ onMouseLeave: () => void
13
+ }
14
+
15
+ export function MainSidebarFlyout({
16
+ item,
17
+ subItems,
18
+ onMouseEnter,
19
+ onMouseLeave,
20
+ }: MainSidebarFlyoutProps) {
21
+ const Icon = item.icon
22
+
23
+ return (
24
+ <div
25
+ onMouseEnter={onMouseEnter}
26
+ onMouseLeave={onMouseLeave}
27
+ className="h-full flex flex-col flex-shrink-0 overflow-hidden bg-gray-50 border-r border-ui-border shadow-[4px_0_20px_-4px_rgba(0,0,0,0.10)] w-72 lg:w-80"
28
+ >
29
+ {/* ── Section label — same style as secondary sidebar section header ── */}
30
+ <div className="h-8 lg:h-10 flex items-center px-3 lg:px-4 flex-shrink-0 gap-2">
31
+ <Icon className="h-3.5 w-3.5 flex-shrink-0" style={{ color: "#4589ff" }} />
32
+ <h2 className="text-xs font-semibold text-text-secondary capitalize tracking-wide truncate flex-1">
33
+ {item.label}
34
+ </h2>
35
+ </div>
36
+
37
+ {/* ── Description ─────────────────────────────────────────────────────── */}
38
+ {item.description && (
39
+ <p className="px-3 lg:px-4 pb-3 text-xs text-text-secondary leading-relaxed">
40
+ {item.description}
41
+ </p>
42
+ )}
43
+
44
+ {/* ── Nav items — identical to secondary sidebar ───────────────────────── */}
45
+ <nav className="flex-1 overflow-y-auto px-2 lg:px-3 py-3 lg:py-4 space-y-0.5 lg:space-y-1">
46
+ {subItems.map((sub) => {
47
+ const SubIcon = sub.icon
48
+ return (
49
+ <a
50
+ key={sub.id}
51
+ href={sub.href}
52
+ className={cn(
53
+ "flex items-start transition-colors border-l-4",
54
+ "px-2 lg:px-3 py-2 gap-x-3",
55
+ "text-text-secondary hover:bg-ui-background hover:text-text-primary",
56
+ "border-transparent hover:border-interactive",
57
+ )}
58
+ >
59
+ <SubIcon className="h-4 w-4 flex-shrink-0 mt-0.5" style={{ color: "#4589ff" }} />
60
+ <div className="min-w-0 flex-1">
61
+ <p className="text-sm leading-snug truncate">{sub.name}</p>
62
+ {sub.description && (
63
+ <p className="text-xs text-text-secondary leading-relaxed mt-0.5 line-clamp-2">{sub.description}</p>
64
+ )}
65
+ </div>
66
+ </a>
67
+ )
68
+ })}
69
+ </nav>
70
+ </div>
71
+ )
72
+ }
@@ -2,11 +2,12 @@
2
2
 
3
3
  import * as React from "react"
4
4
  import { cn } from "../../../lib/utils"
5
-
6
5
  import { Button } from "../../ui/button"
7
6
  import { Logo } from "../../ui/logo"
8
7
  import { type MainMenuItem, type SidebarMenus } from "./data"
9
8
  import { X, Menu, ChevronDown, ChevronRight } from "lucide-react"
9
+ import { useIsMobile } from "../../../hooks/use-mobile"
10
+ import { MainSidebarFlyout } from "./main-sidebar-flyout"
10
11
 
11
12
  export type SidebarMode = 'expanded' | 'minimized' | 'hidden'
12
13
 
@@ -34,27 +35,50 @@ export function MainSidebar({
34
35
  mainMenuItems = [],
35
36
  }: MainSidebarProps) {
36
37
  const isMinimized = mode === 'minimized'
38
+ const isMobile = useIsMobile()
39
+
40
+ // Accordion — mobile only
37
41
  const [expandedMenu, setExpandedMenu] = React.useState<string | null>(null)
42
+ React.useEffect(() => { if (!isOpen) setExpandedMenu(null) }, [isOpen])
43
+
44
+ // Fly-out — desktop only
45
+ const [hoveredItem, setHoveredItem] = React.useState<MainMenuItem | null>(null)
46
+ const closeTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
47
+ React.useEffect(() => { if (isMobile) setHoveredItem(null) }, [isMobile])
48
+
49
+ const cancelCloseTimer = () => {
50
+ if (closeTimerRef.current) {
51
+ clearTimeout(closeTimerRef.current)
52
+ closeTimerRef.current = null
53
+ }
54
+ }
38
55
 
39
- // Reset accordion when sidebar closes
40
- React.useEffect(() => {
41
- if (!isOpen) setExpandedMenu(null)
42
- }, [isOpen])
56
+ const startCloseTimer = () => {
57
+ cancelCloseTimer()
58
+ closeTimerRef.current = setTimeout(() => setHoveredItem(null), 150)
59
+ }
60
+
61
+ const handleNavItemMouseEnter = (item: MainMenuItem) => {
62
+ if (isMobile) return
63
+ const hasSubMenu = (sidebarMenus[item.id]?.length ?? 0) > 0
64
+ if (!hasSubMenu) return
65
+ cancelCloseTimer()
66
+ setHoveredItem(item)
67
+ }
43
68
 
44
69
  const handleMenuClick = (item: MainMenuItem) => {
45
70
  const menuId = item.id
46
71
  const hasSubMenu = (sidebarMenus[menuId]?.length ?? 0) > 0
47
72
 
48
- // Accordion: expand/collapse inline for any non-minimized mode
49
- if (!isMinimized && hasSubMenu) {
73
+ // Mobile: toggle accordion
74
+ if (isMobile && !isMinimized && hasSubMenu) {
50
75
  setExpandedMenu((prev) => (prev === menuId ? null : menuId))
51
76
  return
52
77
  }
53
78
 
79
+ // Desktop: navigate directly
54
80
  onMenuSelect(menuId)
55
- if (!isMinimized && onSecondarySidebarOpen) {
56
- onSecondarySidebarOpen()
57
- }
81
+ if (!isMinimized && onSecondarySidebarOpen) onSecondarySidebarOpen()
58
82
 
59
83
  if (item.href) {
60
84
  const path = item.href.startsWith('http://') || item.href.startsWith('https://')
@@ -63,9 +87,7 @@ export function MainSidebar({
63
87
  window.location.href = path
64
88
  }
65
89
 
66
- if (!isMinimized) {
67
- onToggle()
68
- }
90
+ if (!isMinimized) onToggle()
69
91
  }
70
92
 
71
93
  const buildSubItemHref = (menuId: string, href: string): string => {
@@ -87,119 +109,146 @@ export function MainSidebar({
87
109
 
88
110
  return (
89
111
  <>
112
+ {/* Mobile backdrop — behind the nav+flyout container */}
90
113
  {isOpen && !isMinimized && (
91
- <div
92
- className="fixed inset-0 bg-black/40 z-40"
93
- onClick={onToggle}
94
- />
114
+ <div className="fixed inset-0 bg-black/40 z-40" onClick={onToggle} />
95
115
  )}
96
116
 
117
+ {/*
118
+ Single fixed flex container — nav + flyout are flex children.
119
+ The flyout is never "in front of" the nav; it's always to its right.
120
+ */}
97
121
  <div className={cn(
98
- "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",
99
- isMinimized ? "w-16" : "w-56 lg:w-60 xl:w-64 shadow-xl",
100
- isMinimized
101
- ? "translate-x-0"
102
- : (isOpen ? "translate-x-0" : "-translate-x-full")
122
+ "fixed left-0 top-0 h-full z-50 flex",
123
+ "transform transition-all duration-300 ease-in-out",
124
+ isMinimized ? "translate-x-0" : (isOpen ? "translate-x-0" : "-translate-x-full"),
103
125
  )}>
126
+
127
+ {/* ── Main nav panel ────────────────────────────────────────────────── */}
104
128
  <div className={cn(
105
- "flex items-center h-14 border-b border-ui-border bg-white sticky top-0 z-10",
106
- isMinimized ? "justify-center px-2" : "justify-between px-3 lg:px-4"
129
+ "h-full flex flex-col bg-white border-r border-ui-border overflow-y-auto flex-shrink-0",
130
+ isMinimized ? "w-16" : "w-56 lg:w-60 xl:w-64",
131
+ // Shadow only when flyout is not open (avoid double shadow)
132
+ !hoveredItem && !isMinimized && "shadow-xl",
107
133
  )}>
108
- {!isMinimized && <Logo className="text-2xl font-semibold text-text-primary" href={main_base_url} />}
109
- {isMinimized ? (
110
- <Logo className="text-xl font-semibold text-text-primary" iconOnly href={main_base_url} />
111
- ) : (
112
- <Button
113
- variant="ghost"
114
- size="sm"
115
- onClick={onToggle}
116
- className="h-8 w-8 p-0 hover:bg-ui-background text-text-secondary"
117
- >
118
- <X className="h-4 w-4" />
119
- </Button>
120
- )}
121
- </div>
122
-
123
- <nav className={cn("space-y-0.5 lg:space-y-1", isMinimized ? "p-2" : "p-2 lg:p-3")}>
124
- {mainMenuItems.map((item) => {
125
- const Icon = item.icon
126
- const hasSubMenu = (sidebarMenus[item.id]?.length ?? 0) > 0
127
- const isActive = currentMenu === item.id
128
- const isExpanded = expandedMenu === item.id
129
-
130
- const itemHref = !hasSubMenu && item.href
131
- ? (item.href.startsWith('http://') || item.href.startsWith('https://')
132
- ? new URL(item.href).pathname
133
- : item.href)
134
- : undefined
135
-
136
- const itemClassName = cn(
137
- "w-full flex items-center text-left transition-all duration-150 font-medium",
138
- isMinimized ? "justify-center p-2 lg:p-3" : "gap-2 lg:gap-3 px-2 lg:px-3 py-1.5 lg:py-2",
139
- isActive
140
- ? "bg-interactive/10 text-interactive border-l-4 border-interactive"
141
- : "text-text-primary hover:bg-ui-background border-l-4 border-transparent"
142
- )
143
-
144
- const itemContent = (
134
+ {/* Header */}
135
+ <div className={cn(
136
+ "flex items-center h-14 border-b border-ui-border bg-white sticky top-0 z-10 flex-shrink-0",
137
+ isMinimized ? "justify-center px-2" : "justify-between px-3 lg:px-4",
138
+ )}>
139
+ {isMinimized ? (
140
+ <Logo className="text-xl font-semibold text-text-primary" iconOnly href={main_base_url} />
141
+ ) : (
145
142
  <>
146
- <Icon className={cn(
147
- "h-5 w-5 flex-shrink-0",
148
- isActive ? "text-interactive" : "text-text-secondary"
149
- )} />
150
- {!isMinimized && (
151
- <>
152
- <span className="text-base flex-1">{item.label}</span>
153
- {hasSubMenu && (
154
- isExpanded
155
- ? <ChevronDown className="h-4 w-4 text-text-secondary flex-shrink-0" />
156
- : <ChevronRight className="h-4 w-4 text-text-secondary flex-shrink-0" />
157
- )}
158
- </>
159
- )}
143
+ <Logo className="text-2xl font-semibold text-text-primary" href={main_base_url} />
144
+ <Button
145
+ variant="ghost"
146
+ size="sm"
147
+ onClick={onToggle}
148
+ className="h-8 w-8 p-0 hover:bg-ui-background text-text-secondary"
149
+ >
150
+ <X className="h-4 w-4" />
151
+ </Button>
160
152
  </>
161
- )
162
-
163
- return (
164
- <div key={item.id}>
165
- {itemHref ? (
166
- <a
167
- href={itemHref}
168
- className={itemClassName}
169
- title={isMinimized ? item.label : undefined}
170
- >
171
- {itemContent}
172
- </a>
173
- ) : (
174
- <button
175
- onClick={() => handleMenuClick(item)}
176
- className={itemClassName}
177
- title={isMinimized ? item.label : undefined}
178
- >
179
- {itemContent}
180
- </button>
181
- )}
182
-
183
- {/* Inline accordion sub-items */}
184
- {!isMinimized && isExpanded && hasSubMenu && (
185
- <div className="pl-3 lg:pl-4 pb-1">
186
- {sidebarMenus[item.id].map((subItem) => (
187
- <a
188
- key={subItem.id}
189
- href={buildSubItemHref(item.id, subItem.href)}
190
- onClick={(e) => handleSubMenuClick(e, item.id, subItem.href)}
191
- className="flex items-center gap-2 lg:gap-3 px-2 lg:px-3 py-1.5 lg: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"
192
- >
193
- <subItem.icon className="h-4 w-4 flex-shrink-0 text-text-secondary" />
194
- {subItem.name}
195
- </a>
196
- ))}
197
- </div>
198
- )}
199
- </div>
200
- )
201
- })}
202
- </nav>
153
+ )}
154
+ </div>
155
+
156
+ {/* Nav items */}
157
+ <nav className={cn("flex-1 space-y-0.5 lg:space-y-1", isMinimized ? "p-2" : "p-2 lg:p-3")}>
158
+ {mainMenuItems.map((item) => {
159
+ const Icon = item.icon
160
+ const hasSubMenu = (sidebarMenus[item.id]?.length ?? 0) > 0
161
+ const isActive = currentMenu === item.id
162
+ const isExpanded = expandedMenu === item.id
163
+ const isFlyActive = hoveredItem?.id === item.id
164
+
165
+ const itemHref = !hasSubMenu && item.href
166
+ ? (item.href.startsWith('http://') || item.href.startsWith('https://')
167
+ ? new URL(item.href).pathname
168
+ : item.href)
169
+ : undefined
170
+
171
+ const itemCls = cn(
172
+ "w-full flex items-center text-left transition-all duration-150 font-medium",
173
+ isMinimized ? "justify-center p-2 lg:p-3" : "gap-2 lg:gap-3 px-2 lg:px-3 py-1.5 lg:py-2",
174
+ isActive || isFlyActive
175
+ ? "bg-interactive/10 text-interactive border-l-4 border-interactive"
176
+ : "text-text-primary hover:bg-ui-background border-l-4 border-transparent",
177
+ )
178
+
179
+ const itemContent = (
180
+ <>
181
+ <Icon className={cn(
182
+ "h-5 w-5 flex-shrink-0",
183
+ isActive || isFlyActive ? "text-interactive" : "text-text-secondary",
184
+ )} />
185
+ {!isMinimized && (
186
+ <>
187
+ <span className="text-base flex-1">{item.label}</span>
188
+ {isMobile && hasSubMenu && (
189
+ isExpanded
190
+ ? <ChevronDown className="h-4 w-4 text-text-secondary flex-shrink-0" />
191
+ : <ChevronRight className="h-4 w-4 text-text-secondary flex-shrink-0" />
192
+ )}
193
+ </>
194
+ )}
195
+ </>
196
+ )
197
+
198
+ return (
199
+ <div
200
+ key={item.id}
201
+ onMouseEnter={() => handleNavItemMouseEnter(item)}
202
+ onMouseLeave={startCloseTimer}
203
+ >
204
+ {itemHref ? (
205
+ <a href={itemHref} className={itemCls} title={isMinimized ? item.label : undefined}>
206
+ {itemContent}
207
+ </a>
208
+ ) : (
209
+ <button onClick={() => handleMenuClick(item)} className={itemCls} title={isMinimized ? item.label : undefined}>
210
+ {itemContent}
211
+ </button>
212
+ )}
213
+
214
+ {/* Accordion — mobile only */}
215
+ {isMobile && !isMinimized && isExpanded && hasSubMenu && (
216
+ <div className="pl-3 lg:pl-4 pb-1">
217
+ <p className="px-2 pt-1 pb-0.5 text-[10px] font-semibold text-text-secondary uppercase tracking-widest">
218
+ {item.label}
219
+ </p>
220
+ {sidebarMenus[item.id].map((subItem) => (
221
+ <a
222
+ key={subItem.id}
223
+ href={buildSubItemHref(item.id, subItem.href)}
224
+ onClick={(e) => handleSubMenuClick(e, item.id, subItem.href)}
225
+ className="flex items-center gap-2 lg:gap-3 px-2 lg:px-3 py-1.5 lg: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"
226
+ >
227
+ <subItem.icon className="h-4 w-4 flex-shrink-0 text-text-secondary" />
228
+ {subItem.name}
229
+ </a>
230
+ ))}
231
+ </div>
232
+ )}
233
+ </div>
234
+ )
235
+ })}
236
+ </nav>
237
+ </div>
238
+
239
+ {/* ── Flyout — desktop only, flex child to the right of nav ─────────── */}
240
+ {!isMobile && hoveredItem && (
241
+ <MainSidebarFlyout
242
+ item={hoveredItem}
243
+ subItems={(sidebarMenus[hoveredItem.id] ?? []).map(sub => ({
244
+ ...sub,
245
+ href: buildSubItemHref(hoveredItem.id, sub.href),
246
+ }))}
247
+ onMouseEnter={cancelCloseTimer}
248
+ onMouseLeave={startCloseTimer}
249
+ />
250
+ )}
251
+
203
252
  </div>
204
253
  </>
205
254
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orsetra/shared-ui",
3
- "version": "1.7.7",
3
+ "version": "1.8.0",
4
4
  "description": "Shared UI components for Orsetra platform",
5
5
  "main": "./index.ts",
6
6
  "types": "./index.ts",