@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.
Files changed (142) hide show
  1. package/.claude/ralph-loop.local.md +9 -0
  2. package/README.md +172 -0
  3. package/bin/init.js +269 -0
  4. package/bun.lock +401 -0
  5. package/components.json +28 -0
  6. package/package.json +74 -0
  7. package/scripts/publish-npm.sh +202 -0
  8. package/src/AppShell.tsx +847 -0
  9. package/src/components/PageHeader.tsx +160 -0
  10. package/src/components/data-table/README.md +447 -0
  11. package/src/components/data-table/data-table-preferences.tsx +184 -0
  12. package/src/components/data-table/data-table-toolbar.tsx +118 -0
  13. package/src/components/data-table/data-table.tsx +37 -0
  14. package/src/components/data-table/index.ts +32 -0
  15. package/src/components/global-header/AllServicesButton.tsx +127 -0
  16. package/src/components/global-header/CategoriesButton.tsx +120 -0
  17. package/src/components/global-header/GlobalHeader.tsx +59 -0
  18. package/src/components/global-header/GlobalHeaderSearch.tsx +57 -0
  19. package/src/components/global-header/HeaderUtilities.tsx +243 -0
  20. package/src/components/global-header/ServicesMenu.tsx +246 -0
  21. package/src/components/layout/AppBreadcrumb.tsx +70 -0
  22. package/src/components/layout/AppFlashbar.tsx +95 -0
  23. package/src/components/layout/AppLayout.tsx +271 -0
  24. package/src/components/layout/AppNavigation.tsx +313 -0
  25. package/src/components/layout/AppSidebar.tsx +229 -0
  26. package/src/components/patterns/index.ts +14 -0
  27. package/src/components/patterns/p-alert-5.tsx +19 -0
  28. package/src/components/patterns/p-autocomplete-5.tsx +89 -0
  29. package/src/components/patterns/p-breadcrumb-1.tsx +28 -0
  30. package/src/components/patterns/p-button-42.tsx +37 -0
  31. package/src/components/patterns/p-button-51.tsx +14 -0
  32. package/src/components/patterns/p-button-6.tsx +5 -0
  33. package/src/components/patterns/p-calendar-1.tsx +18 -0
  34. package/src/components/patterns/p-card-1.tsx +33 -0
  35. package/src/components/patterns/p-card-2.tsx +26 -0
  36. package/src/components/patterns/p-card-5.tsx +31 -0
  37. package/src/components/patterns/p-collapsible-7.tsx +121 -0
  38. package/src/components/patterns/p-command-6.tsx +113 -0
  39. package/src/components/patterns/p-dialog-1.tsx +56 -0
  40. package/src/components/patterns/p-dropdown-menu-1.tsx +38 -0
  41. package/src/components/patterns/p-dropdown-menu-11.tsx +122 -0
  42. package/src/components/patterns/p-dropdown-menu-14.tsx +165 -0
  43. package/src/components/patterns/p-dropdown-menu-9.tsx +108 -0
  44. package/src/components/patterns/p-empty-2.tsx +34 -0
  45. package/src/components/patterns/p-file-upload-1.tsx +72 -0
  46. package/src/components/patterns/p-filters-1.tsx +666 -0
  47. package/src/components/patterns/p-frame-2.tsx +26 -0
  48. package/src/components/patterns/p-tabs-2.tsx +129 -0
  49. package/src/components/reui/alert.tsx +92 -0
  50. package/src/components/reui/autocomplete.tsx +343 -0
  51. package/src/components/reui/badge.tsx +87 -0
  52. package/src/components/reui/data-grid/data-grid-column-filter.tsx +165 -0
  53. package/src/components/reui/data-grid/data-grid-column-header.tsx +339 -0
  54. package/src/components/reui/data-grid/data-grid-column-visibility.tsx +55 -0
  55. package/src/components/reui/data-grid/data-grid-pagination.tsx +224 -0
  56. package/src/components/reui/data-grid/data-grid-table-dnd-rows.tsx +260 -0
  57. package/src/components/reui/data-grid/data-grid-table-dnd.tsx +253 -0
  58. package/src/components/reui/data-grid/data-grid-table.tsx +639 -0
  59. package/src/components/reui/data-grid/data-grid.tsx +209 -0
  60. package/src/components/reui/date-selector.tsx +1330 -0
  61. package/src/components/reui/filters.tsx +1869 -0
  62. package/src/components/reui/frame.tsx +134 -0
  63. package/src/components/reui/index.ts +17 -0
  64. package/src/components/reui/timeline.tsx +219 -0
  65. package/src/components/search/Autocomplete.tsx +183 -0
  66. package/src/components/search/AutocompleteClient.tsx +293 -0
  67. package/src/components/search/GlobalSearch.tsx +187 -0
  68. package/src/components/section-drawer/deal-drawer-content.tsx +891 -0
  69. package/src/components/section-drawer/index.ts +19 -0
  70. package/src/components/section-drawer/section-drawer.css +665 -0
  71. package/src/components/section-drawer/section-drawer.tsx +467 -0
  72. package/src/components/sectioned-list-board/README.md +78 -0
  73. package/src/components/sectioned-list-board/board-card-content.tsx +340 -0
  74. package/src/components/sectioned-list-board/date-range-filter.tsx +249 -0
  75. package/src/components/sectioned-list-board/index.ts +19 -0
  76. package/src/components/sectioned-list-board/sectioned-list-board.css +564 -0
  77. package/src/components/sectioned-list-board/sectioned-list-board.tsx +731 -0
  78. package/src/components/sectioned-list-board/sortable-card.tsx +314 -0
  79. package/src/components/sectioned-list-board/sortable-section.tsx +319 -0
  80. package/src/components/sectioned-list-board/types.ts +216 -0
  81. package/src/components/sectioned-list-table/README.md +80 -0
  82. package/src/components/sectioned-list-table/index.ts +14 -0
  83. package/src/components/sectioned-list-table/sectioned-list-table.css +534 -0
  84. package/src/components/sectioned-list-table/sectioned-list-table.tsx +740 -0
  85. package/src/components/sectioned-list-table/sortable-column-header.tsx +120 -0
  86. package/src/components/sectioned-list-table/sortable-row.tsx +420 -0
  87. package/src/components/sectioned-list-table/sortable-section.tsx +251 -0
  88. package/src/components/sectioned-list-table/table-cell-content.tsx +129 -0
  89. package/src/components/sectioned-list-table/types.ts +120 -0
  90. package/src/components/sectioned-list-table/use-column-preferences.ts +103 -0
  91. package/src/components/ui/actions-dropdown.tsx +109 -0
  92. package/src/components/ui/assignee-selector.tsx +209 -0
  93. package/src/components/ui/avatar.tsx +107 -0
  94. package/src/components/ui/breadcrumb.tsx +109 -0
  95. package/src/components/ui/button-group.tsx +83 -0
  96. package/src/components/ui/button.tsx +64 -0
  97. package/src/components/ui/calendar.tsx +220 -0
  98. package/src/components/ui/card.tsx +92 -0
  99. package/src/components/ui/chart.tsx +376 -0
  100. package/src/components/ui/checkbox.tsx +30 -0
  101. package/src/components/ui/collapsible.tsx +33 -0
  102. package/src/components/ui/command.tsx +182 -0
  103. package/src/components/ui/context-menu.tsx +250 -0
  104. package/src/components/ui/create-button-group.tsx +128 -0
  105. package/src/components/ui/dialog.tsx +156 -0
  106. package/src/components/ui/drawer.tsx +133 -0
  107. package/src/components/ui/dropdown-menu.tsx +255 -0
  108. package/src/components/ui/empty.tsx +104 -0
  109. package/src/components/ui/field.tsx +248 -0
  110. package/src/components/ui/form.tsx +165 -0
  111. package/src/components/ui/index.ts +37 -0
  112. package/src/components/ui/input-group.tsx +168 -0
  113. package/src/components/ui/input.tsx +21 -0
  114. package/src/components/ui/kbd.tsx +28 -0
  115. package/src/components/ui/label.tsx +22 -0
  116. package/src/components/ui/navigation-menu.tsx +168 -0
  117. package/src/components/ui/page-header.tsx +80 -0
  118. package/src/components/ui/popover.tsx +87 -0
  119. package/src/components/ui/scroll-area.tsx +56 -0
  120. package/src/components/ui/select.tsx +190 -0
  121. package/src/components/ui/separator.tsx +26 -0
  122. package/src/components/ui/sheet.tsx +141 -0
  123. package/src/components/ui/sidebar.tsx +726 -0
  124. package/src/components/ui/skeleton.tsx +13 -0
  125. package/src/components/ui/sonner.tsx +38 -0
  126. package/src/components/ui/switch.tsx +33 -0
  127. package/src/components/ui/tabs.tsx +91 -0
  128. package/src/components/ui/textarea.tsx +18 -0
  129. package/src/components/ui/toggle-group.tsx +83 -0
  130. package/src/components/ui/toggle.tsx +45 -0
  131. package/src/components/ui/tooltip.tsx +57 -0
  132. package/src/hooks/use-copy-to-clipboard.ts +37 -0
  133. package/src/hooks/use-file-upload.ts +415 -0
  134. package/src/hooks/use-mobile.ts +19 -0
  135. package/src/index.ts +95 -0
  136. package/src/lib/utils.ts +6 -0
  137. package/src/styles.css +1859 -0
  138. package/src/urls.ts +83 -0
  139. package/src/vite.d.ts +22 -0
  140. package/src/vite.js +241 -0
  141. package/tsconfig.base.json +18 -0
  142. 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
+ }