@mostrom/app-shell 0.1.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.
- package/.claude/ralph-loop.local.md +9 -0
- package/README.md +172 -0
- package/bin/init.js +269 -0
- package/bun.lock +401 -0
- package/components.json +28 -0
- package/package.json +74 -0
- package/scripts/publish-npm.sh +202 -0
- package/src/AppShell.tsx +847 -0
- package/src/components/PageHeader.tsx +160 -0
- package/src/components/data-table/README.md +447 -0
- package/src/components/data-table/data-table-preferences.tsx +184 -0
- package/src/components/data-table/data-table-toolbar.tsx +118 -0
- package/src/components/data-table/data-table.tsx +37 -0
- package/src/components/data-table/index.ts +32 -0
- package/src/components/global-header/AllServicesButton.tsx +127 -0
- package/src/components/global-header/CategoriesButton.tsx +120 -0
- package/src/components/global-header/GlobalHeader.tsx +59 -0
- package/src/components/global-header/GlobalHeaderSearch.tsx +57 -0
- package/src/components/global-header/HeaderUtilities.tsx +243 -0
- package/src/components/global-header/ServicesMenu.tsx +246 -0
- package/src/components/layout/AppBreadcrumb.tsx +70 -0
- package/src/components/layout/AppFlashbar.tsx +95 -0
- package/src/components/layout/AppLayout.tsx +271 -0
- package/src/components/layout/AppNavigation.tsx +313 -0
- package/src/components/layout/AppSidebar.tsx +229 -0
- package/src/components/patterns/index.ts +14 -0
- package/src/components/patterns/p-alert-5.tsx +19 -0
- package/src/components/patterns/p-autocomplete-5.tsx +89 -0
- package/src/components/patterns/p-breadcrumb-1.tsx +28 -0
- package/src/components/patterns/p-button-42.tsx +37 -0
- package/src/components/patterns/p-button-51.tsx +14 -0
- package/src/components/patterns/p-button-6.tsx +5 -0
- package/src/components/patterns/p-calendar-1.tsx +18 -0
- package/src/components/patterns/p-card-1.tsx +33 -0
- package/src/components/patterns/p-card-2.tsx +26 -0
- package/src/components/patterns/p-card-5.tsx +31 -0
- package/src/components/patterns/p-collapsible-7.tsx +121 -0
- package/src/components/patterns/p-command-6.tsx +113 -0
- package/src/components/patterns/p-dialog-1.tsx +56 -0
- package/src/components/patterns/p-dropdown-menu-1.tsx +38 -0
- package/src/components/patterns/p-dropdown-menu-11.tsx +122 -0
- package/src/components/patterns/p-dropdown-menu-14.tsx +165 -0
- package/src/components/patterns/p-dropdown-menu-9.tsx +108 -0
- package/src/components/patterns/p-empty-2.tsx +34 -0
- package/src/components/patterns/p-file-upload-1.tsx +72 -0
- package/src/components/patterns/p-filters-1.tsx +666 -0
- package/src/components/patterns/p-frame-2.tsx +26 -0
- package/src/components/patterns/p-tabs-2.tsx +129 -0
- package/src/components/reui/alert.tsx +92 -0
- package/src/components/reui/autocomplete.tsx +343 -0
- package/src/components/reui/badge.tsx +87 -0
- package/src/components/reui/data-grid/data-grid-column-filter.tsx +165 -0
- package/src/components/reui/data-grid/data-grid-column-header.tsx +339 -0
- package/src/components/reui/data-grid/data-grid-column-visibility.tsx +55 -0
- package/src/components/reui/data-grid/data-grid-pagination.tsx +224 -0
- package/src/components/reui/data-grid/data-grid-table-dnd-rows.tsx +260 -0
- package/src/components/reui/data-grid/data-grid-table-dnd.tsx +253 -0
- package/src/components/reui/data-grid/data-grid-table.tsx +639 -0
- package/src/components/reui/data-grid/data-grid.tsx +209 -0
- package/src/components/reui/date-selector.tsx +1330 -0
- package/src/components/reui/filters.tsx +1869 -0
- package/src/components/reui/frame.tsx +134 -0
- package/src/components/reui/index.ts +17 -0
- package/src/components/reui/timeline.tsx +219 -0
- package/src/components/search/Autocomplete.tsx +183 -0
- package/src/components/search/AutocompleteClient.tsx +293 -0
- package/src/components/search/GlobalSearch.tsx +187 -0
- package/src/components/section-drawer/deal-drawer-content.tsx +891 -0
- package/src/components/section-drawer/index.ts +19 -0
- package/src/components/section-drawer/section-drawer.css +665 -0
- package/src/components/section-drawer/section-drawer.tsx +467 -0
- package/src/components/sectioned-list-board/README.md +78 -0
- package/src/components/sectioned-list-board/board-card-content.tsx +340 -0
- package/src/components/sectioned-list-board/date-range-filter.tsx +249 -0
- package/src/components/sectioned-list-board/index.ts +19 -0
- package/src/components/sectioned-list-board/sectioned-list-board.css +564 -0
- package/src/components/sectioned-list-board/sectioned-list-board.tsx +731 -0
- package/src/components/sectioned-list-board/sortable-card.tsx +314 -0
- package/src/components/sectioned-list-board/sortable-section.tsx +319 -0
- package/src/components/sectioned-list-board/types.ts +216 -0
- package/src/components/sectioned-list-table/README.md +80 -0
- package/src/components/sectioned-list-table/index.ts +14 -0
- package/src/components/sectioned-list-table/sectioned-list-table.css +534 -0
- package/src/components/sectioned-list-table/sectioned-list-table.tsx +740 -0
- package/src/components/sectioned-list-table/sortable-column-header.tsx +120 -0
- package/src/components/sectioned-list-table/sortable-row.tsx +420 -0
- package/src/components/sectioned-list-table/sortable-section.tsx +251 -0
- package/src/components/sectioned-list-table/table-cell-content.tsx +129 -0
- package/src/components/sectioned-list-table/types.ts +120 -0
- package/src/components/sectioned-list-table/use-column-preferences.ts +103 -0
- package/src/components/ui/actions-dropdown.tsx +109 -0
- package/src/components/ui/assignee-selector.tsx +209 -0
- package/src/components/ui/avatar.tsx +107 -0
- package/src/components/ui/breadcrumb.tsx +109 -0
- package/src/components/ui/button-group.tsx +83 -0
- package/src/components/ui/button.tsx +64 -0
- package/src/components/ui/calendar.tsx +220 -0
- package/src/components/ui/card.tsx +92 -0
- package/src/components/ui/chart.tsx +376 -0
- package/src/components/ui/checkbox.tsx +30 -0
- package/src/components/ui/collapsible.tsx +33 -0
- package/src/components/ui/command.tsx +182 -0
- package/src/components/ui/context-menu.tsx +250 -0
- package/src/components/ui/create-button-group.tsx +128 -0
- package/src/components/ui/dialog.tsx +156 -0
- package/src/components/ui/drawer.tsx +133 -0
- package/src/components/ui/dropdown-menu.tsx +255 -0
- package/src/components/ui/empty.tsx +104 -0
- package/src/components/ui/field.tsx +248 -0
- package/src/components/ui/form.tsx +165 -0
- package/src/components/ui/index.ts +37 -0
- package/src/components/ui/input-group.tsx +168 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/kbd.tsx +28 -0
- package/src/components/ui/label.tsx +22 -0
- package/src/components/ui/navigation-menu.tsx +168 -0
- package/src/components/ui/page-header.tsx +80 -0
- package/src/components/ui/popover.tsx +87 -0
- package/src/components/ui/scroll-area.tsx +56 -0
- package/src/components/ui/select.tsx +190 -0
- package/src/components/ui/separator.tsx +26 -0
- package/src/components/ui/sheet.tsx +141 -0
- package/src/components/ui/sidebar.tsx +726 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/sonner.tsx +38 -0
- package/src/components/ui/switch.tsx +33 -0
- package/src/components/ui/tabs.tsx +91 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/toggle-group.tsx +83 -0
- package/src/components/ui/toggle.tsx +45 -0
- package/src/components/ui/tooltip.tsx +57 -0
- package/src/hooks/use-copy-to-clipboard.ts +37 -0
- package/src/hooks/use-file-upload.ts +415 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/index.ts +95 -0
- package/src/lib/utils.ts +6 -0
- package/src/styles.css +1859 -0
- package/src/urls.ts +83 -0
- package/src/vite.d.ts +22 -0
- package/src/vite.js +241 -0
- package/tsconfig.base.json +18 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { Menu, Bolt, Settings } from "lucide-react";
|
|
5
|
+
import { cn } from "../../lib/utils";
|
|
6
|
+
|
|
7
|
+
export interface AppLayoutProps {
|
|
8
|
+
/** Content for the left navigation panel */
|
|
9
|
+
navigation?: React.ReactNode;
|
|
10
|
+
/** Whether the navigation panel is open */
|
|
11
|
+
navigationOpen?: boolean;
|
|
12
|
+
/** Callback when navigation open state changes */
|
|
13
|
+
onNavigationChange?: (open: boolean) => void;
|
|
14
|
+
/** Hide the navigation panel entirely */
|
|
15
|
+
navigationHide?: boolean;
|
|
16
|
+
/** Content for the right tools panel */
|
|
17
|
+
tools?: React.ReactNode;
|
|
18
|
+
/** Whether the tools panel is open */
|
|
19
|
+
toolsOpen?: boolean;
|
|
20
|
+
/** Callback when tools panel open state changes */
|
|
21
|
+
onToolsChange?: (open: boolean) => void;
|
|
22
|
+
/** Hide the tools panel entirely */
|
|
23
|
+
toolsHide?: boolean;
|
|
24
|
+
/** Content for the copilot panel */
|
|
25
|
+
copilot?: React.ReactNode;
|
|
26
|
+
/** Whether the copilot panel is open */
|
|
27
|
+
copilotOpen?: boolean;
|
|
28
|
+
/** Callback when copilot panel open state changes */
|
|
29
|
+
onCopilotChange?: (open: boolean) => void;
|
|
30
|
+
/** Hide the copilot button entirely */
|
|
31
|
+
copilotHide?: boolean;
|
|
32
|
+
/** Breadcrumbs to display above the content */
|
|
33
|
+
breadcrumbs?: React.ReactNode;
|
|
34
|
+
/** Notifications/alerts to display below breadcrumbs */
|
|
35
|
+
notifications?: React.ReactNode;
|
|
36
|
+
/** Header content above the main content */
|
|
37
|
+
contentHeader?: React.ReactNode;
|
|
38
|
+
/** Main content */
|
|
39
|
+
content?: React.ReactNode;
|
|
40
|
+
/** Disable default content padding */
|
|
41
|
+
disableContentPaddings?: boolean;
|
|
42
|
+
/** Maximum content width in pixels */
|
|
43
|
+
maxContentWidth?: number;
|
|
44
|
+
/** Minimum content width in pixels */
|
|
45
|
+
minContentWidth?: number;
|
|
46
|
+
/** Children (alternative to content prop) */
|
|
47
|
+
children?: React.ReactNode;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const NAVIGATION_WIDTH = 280;
|
|
51
|
+
const TOOLS_WIDTH = 320;
|
|
52
|
+
const COPILOT_WIDTH = 400;
|
|
53
|
+
const HEADER_HEIGHT = 56;
|
|
54
|
+
const SERVICE_BAR_HEIGHT = 56;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* AppLayout - A custom layout component replacing Cloudscape's AppLayoutToolbar.
|
|
58
|
+
*
|
|
59
|
+
* Layout structure:
|
|
60
|
+
* ┌──────────────────────────────────────────────────────────────────────┐
|
|
61
|
+
* │ Service Bar (full width) - nav toggle + breadcrumbs + tools toggle │
|
|
62
|
+
* ├──────────────┬───────────────────────────────────────┬───────────────┤
|
|
63
|
+
* │ Sidebar │ Content Area │ Tools Panel │
|
|
64
|
+
* │ (navigation) │ - notifications │ (right) │
|
|
65
|
+
* │ │ - content header │ │
|
|
66
|
+
* │ │ - main content │ │
|
|
67
|
+
* └──────────────┴───────────────────────────────────────┴───────────────┘
|
|
68
|
+
*/
|
|
69
|
+
export function AppLayout({
|
|
70
|
+
navigation,
|
|
71
|
+
navigationOpen = true,
|
|
72
|
+
onNavigationChange,
|
|
73
|
+
navigationHide = false,
|
|
74
|
+
tools,
|
|
75
|
+
toolsOpen = false,
|
|
76
|
+
onToolsChange,
|
|
77
|
+
toolsHide = false,
|
|
78
|
+
copilot,
|
|
79
|
+
copilotOpen = false,
|
|
80
|
+
onCopilotChange,
|
|
81
|
+
copilotHide = false,
|
|
82
|
+
breadcrumbs,
|
|
83
|
+
notifications,
|
|
84
|
+
contentHeader,
|
|
85
|
+
content,
|
|
86
|
+
disableContentPaddings = false,
|
|
87
|
+
maxContentWidth,
|
|
88
|
+
minContentWidth,
|
|
89
|
+
children,
|
|
90
|
+
}: AppLayoutProps) {
|
|
91
|
+
const mainContent = content ?? children;
|
|
92
|
+
const showToolsPanel = !toolsHide && tools;
|
|
93
|
+
const showCopilotButton = !copilotHide;
|
|
94
|
+
|
|
95
|
+
const handleToggleNavigation = () => {
|
|
96
|
+
onNavigationChange?.(!navigationOpen);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const handleToggleTools = () => {
|
|
100
|
+
onToolsChange?.(!toolsOpen);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const handleToggleCopilot = () => {
|
|
104
|
+
onCopilotChange?.(!copilotOpen);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div
|
|
109
|
+
className="flex flex-col overflow-hidden bg-background"
|
|
110
|
+
style={{ height: `calc(100vh - ${HEADER_HEIGHT}px)` }}
|
|
111
|
+
>
|
|
112
|
+
{/* Service Bar - spans full width */}
|
|
113
|
+
<div
|
|
114
|
+
className="flex items-center border-b border-border bg-background text-foreground"
|
|
115
|
+
style={{ height: SERVICE_BAR_HEIGHT, minHeight: SERVICE_BAR_HEIGHT }}
|
|
116
|
+
>
|
|
117
|
+
{/* Navigation Toggle Button */}
|
|
118
|
+
{!navigationHide && (
|
|
119
|
+
<div className="flex items-center" style={{ paddingLeft: 24, paddingRight: 12 }}>
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
onClick={handleToggleNavigation}
|
|
123
|
+
className="service-bar-button"
|
|
124
|
+
aria-label={navigationOpen ? "Close navigation" : "Open navigation"}
|
|
125
|
+
aria-expanded={navigationOpen}
|
|
126
|
+
>
|
|
127
|
+
<Menu className="h-5 w-5" />
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
|
|
132
|
+
{/* Breadcrumbs */}
|
|
133
|
+
{breadcrumbs && (
|
|
134
|
+
<div className="flex-1 min-w-0 px-4">
|
|
135
|
+
{breadcrumbs}
|
|
136
|
+
</div>
|
|
137
|
+
)}
|
|
138
|
+
|
|
139
|
+
{/* Spacer if no breadcrumbs */}
|
|
140
|
+
{!breadcrumbs && <div className="flex-1" />}
|
|
141
|
+
|
|
142
|
+
{/* Right side buttons container */}
|
|
143
|
+
<div className="flex items-center gap-1" style={{ paddingLeft: 12, paddingRight: 24 }}>
|
|
144
|
+
{/* Copilot Toggle Button */}
|
|
145
|
+
{showCopilotButton && (
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
onClick={handleToggleCopilot}
|
|
149
|
+
className="service-bar-button"
|
|
150
|
+
aria-label={copilotOpen ? "Close copilot" : "Open copilot"}
|
|
151
|
+
aria-expanded={copilotOpen}
|
|
152
|
+
>
|
|
153
|
+
<Bolt className="h-5 w-5" />
|
|
154
|
+
</button>
|
|
155
|
+
)}
|
|
156
|
+
|
|
157
|
+
{/* Tools Toggle Button */}
|
|
158
|
+
{showToolsPanel && (
|
|
159
|
+
<button
|
|
160
|
+
type="button"
|
|
161
|
+
onClick={handleToggleTools}
|
|
162
|
+
className="service-bar-button"
|
|
163
|
+
aria-label={toolsOpen ? "Close tools panel" : "Open tools panel"}
|
|
164
|
+
aria-expanded={toolsOpen}
|
|
165
|
+
>
|
|
166
|
+
<Settings className="h-5 w-5" />
|
|
167
|
+
</button>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
{/* Content Area - sidebar + main content + tools panel side by side */}
|
|
173
|
+
<div className="flex flex-1 overflow-hidden">
|
|
174
|
+
{/* Navigation Panel */}
|
|
175
|
+
{!navigationHide && (
|
|
176
|
+
<aside
|
|
177
|
+
className="flex-shrink-0 bg-sidebar overflow-hidden"
|
|
178
|
+
style={{
|
|
179
|
+
width: navigationOpen ? NAVIGATION_WIDTH : 0,
|
|
180
|
+
borderRight: navigationOpen ? '1px solid #d1d5db' : 'none',
|
|
181
|
+
transition: 'width 300ms cubic-bezier(0.4, 0, 0.2, 1)'
|
|
182
|
+
}}
|
|
183
|
+
aria-hidden={!navigationOpen}
|
|
184
|
+
{...(!navigationOpen && { inert: true })}
|
|
185
|
+
>
|
|
186
|
+
<div
|
|
187
|
+
className="h-full overflow-y-auto"
|
|
188
|
+
style={{
|
|
189
|
+
width: NAVIGATION_WIDTH,
|
|
190
|
+
opacity: navigationOpen ? 1 : 0,
|
|
191
|
+
transition: 'opacity 200ms ease-in-out'
|
|
192
|
+
}}
|
|
193
|
+
>
|
|
194
|
+
{navigation}
|
|
195
|
+
</div>
|
|
196
|
+
</aside>
|
|
197
|
+
)}
|
|
198
|
+
|
|
199
|
+
{/* Main Content */}
|
|
200
|
+
<main className="flex-1 overflow-y-auto min-w-0">
|
|
201
|
+
<div
|
|
202
|
+
className={cn("mx-auto h-full", !disableContentPaddings && "p-6")}
|
|
203
|
+
style={{
|
|
204
|
+
maxWidth: maxContentWidth ? `${maxContentWidth}px` : undefined,
|
|
205
|
+
minWidth: minContentWidth ? `${minContentWidth}px` : undefined,
|
|
206
|
+
}}
|
|
207
|
+
>
|
|
208
|
+
{/* Notifications */}
|
|
209
|
+
{notifications && <div className="mb-4">{notifications}</div>}
|
|
210
|
+
|
|
211
|
+
{/* Content Header */}
|
|
212
|
+
{contentHeader && <div className="mb-6">{contentHeader}</div>}
|
|
213
|
+
|
|
214
|
+
{/* Main Content */}
|
|
215
|
+
{mainContent}
|
|
216
|
+
</div>
|
|
217
|
+
</main>
|
|
218
|
+
|
|
219
|
+
{/* Tools Panel */}
|
|
220
|
+
{showToolsPanel && (
|
|
221
|
+
<aside
|
|
222
|
+
className="flex-shrink-0 bg-sidebar overflow-hidden"
|
|
223
|
+
style={{
|
|
224
|
+
width: toolsOpen ? TOOLS_WIDTH : 0,
|
|
225
|
+
borderLeft: toolsOpen ? '1px solid #d1d5db' : 'none',
|
|
226
|
+
transition: 'width 300ms cubic-bezier(0.4, 0, 0.2, 1)'
|
|
227
|
+
}}
|
|
228
|
+
aria-hidden={!toolsOpen}
|
|
229
|
+
{...(!toolsOpen && { inert: true })}
|
|
230
|
+
>
|
|
231
|
+
<div
|
|
232
|
+
className="h-full overflow-y-auto"
|
|
233
|
+
style={{
|
|
234
|
+
width: TOOLS_WIDTH,
|
|
235
|
+
opacity: toolsOpen ? 1 : 0,
|
|
236
|
+
transition: 'opacity 200ms ease-in-out'
|
|
237
|
+
}}
|
|
238
|
+
>
|
|
239
|
+
{tools}
|
|
240
|
+
</div>
|
|
241
|
+
</aside>
|
|
242
|
+
)}
|
|
243
|
+
|
|
244
|
+
{/* Copilot Panel */}
|
|
245
|
+
{showCopilotButton && (
|
|
246
|
+
<aside
|
|
247
|
+
className="flex-shrink-0 bg-sidebar overflow-hidden"
|
|
248
|
+
style={{
|
|
249
|
+
width: copilotOpen ? COPILOT_WIDTH : 0,
|
|
250
|
+
borderLeft: copilotOpen ? '1px solid #d1d5db' : 'none',
|
|
251
|
+
transition: 'width 300ms cubic-bezier(0.4, 0, 0.2, 1)'
|
|
252
|
+
}}
|
|
253
|
+
aria-hidden={!copilotOpen}
|
|
254
|
+
{...(!copilotOpen && { inert: true })}
|
|
255
|
+
>
|
|
256
|
+
<div
|
|
257
|
+
className="h-full overflow-y-auto"
|
|
258
|
+
style={{
|
|
259
|
+
width: COPILOT_WIDTH,
|
|
260
|
+
opacity: copilotOpen ? 1 : 0,
|
|
261
|
+
transition: 'opacity 200ms ease-in-out'
|
|
262
|
+
}}
|
|
263
|
+
>
|
|
264
|
+
{copilot}
|
|
265
|
+
</div>
|
|
266
|
+
</aside>
|
|
267
|
+
)}
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { ChevronLeft } from "lucide-react";
|
|
3
|
+
import { cn } from "../../lib/utils";
|
|
4
|
+
|
|
5
|
+
/** Base navigation item with common properties */
|
|
6
|
+
interface BaseNavigationItem {
|
|
7
|
+
/** Optional info or additional content */
|
|
8
|
+
info?: React.ReactNode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** A link item in the navigation */
|
|
12
|
+
export interface NavigationLinkItem extends BaseNavigationItem {
|
|
13
|
+
type: "link";
|
|
14
|
+
/** Display text for the link */
|
|
15
|
+
text: string;
|
|
16
|
+
/** URL the link points to */
|
|
17
|
+
href?: string;
|
|
18
|
+
/** Whether link opens in a new tab */
|
|
19
|
+
external?: boolean;
|
|
20
|
+
/** Nested link items */
|
|
21
|
+
items?: ReadonlyArray<NavigationLinkItem>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A divider line between navigation items */
|
|
25
|
+
export interface NavigationDividerItem {
|
|
26
|
+
type: "divider";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A section with a title and nested items */
|
|
30
|
+
export interface NavigationSectionItem extends BaseNavigationItem {
|
|
31
|
+
type: "section";
|
|
32
|
+
/** Section title */
|
|
33
|
+
text?: string;
|
|
34
|
+
/** Items in this section */
|
|
35
|
+
items?: ReadonlyArray<NavigationItem>;
|
|
36
|
+
/** Default expanded state */
|
|
37
|
+
defaultExpanded?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A group of sections */
|
|
41
|
+
export interface NavigationSectionGroupItem extends BaseNavigationItem {
|
|
42
|
+
type: "section-group";
|
|
43
|
+
/** Group title */
|
|
44
|
+
title?: string;
|
|
45
|
+
/** Items in this group */
|
|
46
|
+
items?: ReadonlyArray<NavigationItem>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** An expandable group of links */
|
|
50
|
+
export interface NavigationExpandableLinkGroupItem extends BaseNavigationItem {
|
|
51
|
+
type: "expandable-link-group";
|
|
52
|
+
/** Group title */
|
|
53
|
+
text?: string;
|
|
54
|
+
/** URL for the group header */
|
|
55
|
+
href?: string;
|
|
56
|
+
/** Items in this group */
|
|
57
|
+
items?: ReadonlyArray<NavigationItem>;
|
|
58
|
+
/** Default expanded state */
|
|
59
|
+
defaultExpanded?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Union of all navigation item types */
|
|
63
|
+
export type NavigationItem =
|
|
64
|
+
| NavigationLinkItem
|
|
65
|
+
| NavigationDividerItem
|
|
66
|
+
| NavigationSectionItem
|
|
67
|
+
| NavigationSectionGroupItem
|
|
68
|
+
| NavigationExpandableLinkGroupItem;
|
|
69
|
+
|
|
70
|
+
/** Detail passed to onFollow callback */
|
|
71
|
+
export interface NavigationFollowDetail {
|
|
72
|
+
href: string;
|
|
73
|
+
external: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Event passed to onFollow callback */
|
|
77
|
+
export interface NavigationFollowEvent {
|
|
78
|
+
detail: NavigationFollowDetail;
|
|
79
|
+
preventDefault: () => void;
|
|
80
|
+
stopPropagation: () => void;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface AppNavigationProps {
|
|
84
|
+
/** Service name displayed in the header */
|
|
85
|
+
serviceName: string;
|
|
86
|
+
/** Link destination when clicking the service name */
|
|
87
|
+
headerHref?: string;
|
|
88
|
+
/** Navigation items for the sidebar */
|
|
89
|
+
items?: ReadonlyArray<NavigationItem>;
|
|
90
|
+
/** Currently active navigation item href for highlighting */
|
|
91
|
+
activeHref?: string;
|
|
92
|
+
/** Callback when a navigation item is clicked */
|
|
93
|
+
onFollow?: (event: NavigationFollowEvent) => void;
|
|
94
|
+
/** Callback when the close button is clicked */
|
|
95
|
+
onClose?: () => void;
|
|
96
|
+
/** Additional className for the navigation */
|
|
97
|
+
className?: string;
|
|
98
|
+
/** Base path to prepend to all internal hrefs (e.g., "/demo") */
|
|
99
|
+
basePath?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* AppNavigation - A navigation component that provides Cloudscape-compatible API
|
|
104
|
+
* using Tailwind styling. Designed to work within AppLayout's navigation slot.
|
|
105
|
+
*/
|
|
106
|
+
export function AppNavigation({
|
|
107
|
+
serviceName,
|
|
108
|
+
headerHref = "#",
|
|
109
|
+
items = [],
|
|
110
|
+
activeHref,
|
|
111
|
+
onFollow,
|
|
112
|
+
onClose,
|
|
113
|
+
className,
|
|
114
|
+
basePath = "",
|
|
115
|
+
}: AppNavigationProps) {
|
|
116
|
+
/**
|
|
117
|
+
* Resolves a href by prepending the basePath for internal links.
|
|
118
|
+
* External links and anchors (#) are returned unchanged.
|
|
119
|
+
*/
|
|
120
|
+
const resolveHref = (href: string | undefined, external?: boolean): string => {
|
|
121
|
+
if (!href) return "#";
|
|
122
|
+
// Don't modify external links, anchors, or absolute URLs
|
|
123
|
+
if (external || href === "#" || href.startsWith("http://") || href.startsWith("https://")) {
|
|
124
|
+
return href;
|
|
125
|
+
}
|
|
126
|
+
// Don't double-prepend if href already starts with basePath
|
|
127
|
+
if (basePath && href.startsWith(basePath)) {
|
|
128
|
+
return href;
|
|
129
|
+
}
|
|
130
|
+
// Prepend basePath for internal links
|
|
131
|
+
if (basePath && href.startsWith("/")) {
|
|
132
|
+
return `${basePath}${href}`;
|
|
133
|
+
}
|
|
134
|
+
return href;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const handleClick = (
|
|
138
|
+
event: React.MouseEvent,
|
|
139
|
+
item: { href?: string; external?: boolean }
|
|
140
|
+
) => {
|
|
141
|
+
if (onFollow) {
|
|
142
|
+
const followEvent: NavigationFollowEvent = {
|
|
143
|
+
detail: {
|
|
144
|
+
href: item.href ?? "",
|
|
145
|
+
external: item.external ?? false,
|
|
146
|
+
},
|
|
147
|
+
preventDefault: () => event.preventDefault(),
|
|
148
|
+
stopPropagation: () => event.stopPropagation(),
|
|
149
|
+
};
|
|
150
|
+
onFollow(followEvent);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const renderItem = (item: NavigationItem, index: number): React.ReactNode => {
|
|
155
|
+
if (item.type === "divider") {
|
|
156
|
+
return (
|
|
157
|
+
<div
|
|
158
|
+
key={`divider-${index}`}
|
|
159
|
+
className="my-2 h-px bg-border"
|
|
160
|
+
/>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (item.type === "section") {
|
|
165
|
+
return (
|
|
166
|
+
<div key={`section-${index}`} className="mb-4">
|
|
167
|
+
{item.text && (
|
|
168
|
+
<div className="mb-2 px-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
|
169
|
+
{item.text}
|
|
170
|
+
</div>
|
|
171
|
+
)}
|
|
172
|
+
<ul className="space-y-1">
|
|
173
|
+
{item.items?.map((subItem, subIndex) =>
|
|
174
|
+
renderItem(subItem, subIndex)
|
|
175
|
+
)}
|
|
176
|
+
</ul>
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (item.type === "section-group") {
|
|
182
|
+
return (
|
|
183
|
+
<div key={`section-group-${index}`} className="mb-4">
|
|
184
|
+
{item.title && (
|
|
185
|
+
<div className="mb-2 px-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
|
186
|
+
{item.title}
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
189
|
+
<ul className="space-y-1">
|
|
190
|
+
{item.items?.map((subItem, subIndex) =>
|
|
191
|
+
renderItem(subItem, subIndex)
|
|
192
|
+
)}
|
|
193
|
+
</ul>
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (item.type === "link") {
|
|
199
|
+
const isActive = activeHref === item.href;
|
|
200
|
+
|
|
201
|
+
if (item.items && item.items.length > 0) {
|
|
202
|
+
return (
|
|
203
|
+
<li key={`link-${index}`}>
|
|
204
|
+
<a
|
|
205
|
+
href={resolveHref(item.href, item.external)}
|
|
206
|
+
onClick={(e) => handleClick(e, item)}
|
|
207
|
+
target={item.external ? "_blank" : undefined}
|
|
208
|
+
rel={item.external ? "noopener noreferrer" : undefined}
|
|
209
|
+
className={cn(
|
|
210
|
+
"flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors",
|
|
211
|
+
"hover:bg-accent hover:text-accent-foreground",
|
|
212
|
+
isActive && "bg-accent text-accent-foreground font-medium"
|
|
213
|
+
)}
|
|
214
|
+
>
|
|
215
|
+
{item.text}
|
|
216
|
+
</a>
|
|
217
|
+
<ul className="ml-4 mt-1 space-y-1 border-l border-border pl-2">
|
|
218
|
+
{item.items.map((subItem, subIndex) => {
|
|
219
|
+
const isSubActive = activeHref === subItem.href;
|
|
220
|
+
return (
|
|
221
|
+
<li key={`sublink-${subIndex}`}>
|
|
222
|
+
<a
|
|
223
|
+
href={resolveHref(subItem.href, subItem.external)}
|
|
224
|
+
onClick={(e) => handleClick(e, subItem)}
|
|
225
|
+
target={subItem.external ? "_blank" : undefined}
|
|
226
|
+
rel={subItem.external ? "noopener noreferrer" : undefined}
|
|
227
|
+
className={cn(
|
|
228
|
+
"flex items-center gap-2 rounded-md px-3 py-1.5 text-sm transition-colors",
|
|
229
|
+
"hover:bg-accent hover:text-accent-foreground",
|
|
230
|
+
isSubActive && "bg-accent text-accent-foreground font-medium"
|
|
231
|
+
)}
|
|
232
|
+
>
|
|
233
|
+
{subItem.text}
|
|
234
|
+
</a>
|
|
235
|
+
</li>
|
|
236
|
+
);
|
|
237
|
+
})}
|
|
238
|
+
</ul>
|
|
239
|
+
</li>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return (
|
|
244
|
+
<li key={`link-${index}`}>
|
|
245
|
+
<a
|
|
246
|
+
href={resolveHref(item.href, item.external)}
|
|
247
|
+
onClick={(e) => handleClick(e, item)}
|
|
248
|
+
target={item.external ? "_blank" : undefined}
|
|
249
|
+
rel={item.external ? "noopener noreferrer" : undefined}
|
|
250
|
+
className={cn(
|
|
251
|
+
"flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors",
|
|
252
|
+
"hover:bg-accent hover:text-accent-foreground",
|
|
253
|
+
isActive && "bg-accent text-accent-foreground font-medium"
|
|
254
|
+
)}
|
|
255
|
+
>
|
|
256
|
+
{item.text}
|
|
257
|
+
</a>
|
|
258
|
+
</li>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (item.type === "expandable-link-group") {
|
|
263
|
+
return (
|
|
264
|
+
<div key={`expandable-${index}`} className="mb-4">
|
|
265
|
+
{item.text && (
|
|
266
|
+
<div className="mb-2 px-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
|
267
|
+
{item.text}
|
|
268
|
+
</div>
|
|
269
|
+
)}
|
|
270
|
+
<ul className="space-y-1">
|
|
271
|
+
{item.items?.map((subItem, subIndex) =>
|
|
272
|
+
renderItem(subItem, subIndex)
|
|
273
|
+
)}
|
|
274
|
+
</ul>
|
|
275
|
+
</div>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return null;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
<nav className={cn("flex flex-col h-full", className)}>
|
|
284
|
+
{/* Header */}
|
|
285
|
+
<div className="flex items-center justify-between px-4 py-4">
|
|
286
|
+
<a
|
|
287
|
+
href={resolveHref(headerHref)}
|
|
288
|
+
onClick={(e) => handleClick(e, { href: headerHref })}
|
|
289
|
+
className="block px-3 text-lg font-bold text-foreground hover:text-foreground/80 transition-colors"
|
|
290
|
+
>
|
|
291
|
+
{serviceName}
|
|
292
|
+
</a>
|
|
293
|
+
{onClose && (
|
|
294
|
+
<button
|
|
295
|
+
type="button"
|
|
296
|
+
onClick={onClose}
|
|
297
|
+
className="service-bar-button"
|
|
298
|
+
aria-label="Close sidebar"
|
|
299
|
+
>
|
|
300
|
+
<ChevronLeft className="h-5 w-5" />
|
|
301
|
+
</button>
|
|
302
|
+
)}
|
|
303
|
+
</div>
|
|
304
|
+
|
|
305
|
+
{/* Navigation Items */}
|
|
306
|
+
<div className="flex-1 overflow-y-auto px-4 py-2">
|
|
307
|
+
<ul className="space-y-1">
|
|
308
|
+
{items.map((item, index) => renderItem(item, index))}
|
|
309
|
+
</ul>
|
|
310
|
+
</div>
|
|
311
|
+
</nav>
|
|
312
|
+
);
|
|
313
|
+
}
|