@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:
|
|
50
|
-
name:
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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:
|
|
81
|
-
label:
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
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
|
-
//
|
|
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
|
|
99
|
-
|
|
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
|
|
106
|
-
isMinimized ? "
|
|
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
|
-
{
|
|
109
|
-
{
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
<
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
</
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
)
|