@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,847 @@
1
+ /**
2
+ * AppShell Component
3
+ *
4
+ * The primary layout wrapper for all platform services. Implements AWS Cloudscape
5
+ * design system patterns to ensure visual and behavioral consistency.
6
+ *
7
+ * LAYOUT STRUCTURE:
8
+ * ┌─────────────────────────────────────────────────────────────────────────────┐
9
+ * │ GLOBAL HEADER (TopNavigation) │
10
+ * │ ┌─────────────┬──────────────────────────────┬────────────────────────────┐ │
11
+ * │ │ Identity │ All Services | Search Bar │ Settings | Theme | User │ │
12
+ * │ │ (Logo/Name) │ (9-dot grid) (Global) │ (Dropdowns) │ │
13
+ * │ └─────────────┴──────────────────────────────┴────────────────────────────┘ │
14
+ * ├─────────────────────────────────────────────────────────────────────────────┤
15
+ * │ MAIN LAYOUT (AppLayoutToolbar) │
16
+ * │ ┌────────────┬────────────────────────────────────────────┬───────────────┐ │
17
+ * │ │ Side Nav │ Content Area │ Drawers │ │
18
+ * │ │ (Service │ ┌────────────────────────────────────────┐ │ (Chatbot, │ │
19
+ * │ │ pages) │ │ Breadcrumbs │ │ Tools) │ │
20
+ * │ │ │ ├────────────────────────────────────────┤ │ │ │
21
+ * │ │ │ │ Notifications (Flashbar) │ │ │ │
22
+ * │ │ │ ├────────────────────────────────────────┤ │ │ │
23
+ * │ │ │ │ Content Header │ │ │ │
24
+ * │ │ │ ├────────────────────────────────────────┤ │ │ │
25
+ * │ │ │ │ Main Content (children) │ │ │ │
26
+ * │ │ │ │ │ │ │ │
27
+ * │ │ │ └────────────────────────────────────────┘ │ │ │
28
+ * │ └────────────┴────────────────────────────────────────────┴───────────────┘ │
29
+ * └─────────────────────────────────────────────────────────────────────────────┘
30
+ *
31
+ * KEY COMPONENTS:
32
+ * - Global Header: Persistent top bar with platform branding and global actions
33
+ * - All Services Button: 9-dot grid icon for accessing all platform services
34
+ * - Global Search: Search bar for finding services, features, and documentation
35
+ * - Side Navigation: Collapsible left panel for service-specific page navigation
36
+ * - Content Area: Main area for page content with breadcrumbs and notifications
37
+ * - Drawers: Slide-in panels from the right (e.g., AI chatbot assistant)
38
+ *
39
+ * USAGE:
40
+ * Wrap your service's pages with AppShell to inherit the standard platform layout.
41
+ * Pass service-specific navigation items and content through props.
42
+ */
43
+
44
+ import React from "react";
45
+
46
+ import "@cloudscape-design/global-styles/index.css";
47
+ import "./styles.css";
48
+
49
+ import { AppFlashbar, type FlashbarMessage } from "./components/layout/AppFlashbar";
50
+ import type { NavigationItem, NavigationFollowEvent } from "./components/layout/AppNavigation";
51
+ import { AppBreadcrumb, type BreadcrumbItem } from "./components/layout/AppBreadcrumb";
52
+
53
+ // Type aliases for navigation - maintaining export names for backward compatibility
54
+ export type CompatibleNavigationItem = NavigationItem;
55
+ export type CompatibleNavigationFollowEvent = NavigationFollowEvent;
56
+ import { AppNavigation } from "./components/layout/AppNavigation";
57
+ import { AppLayout } from "./components/layout/AppLayout";
58
+ import type {
59
+ MenuDropdownItems,
60
+ MenuItemClickHandler,
61
+ } from "./components/global-header/ServicesMenu";
62
+ import {
63
+ getServiceCatalog,
64
+ getServiceCatalogSync,
65
+ groupServicesByCategory,
66
+ searchServices,
67
+ type ServiceItem,
68
+ } from "@platform/service-catalog";
69
+ import { basePath } from "./urls";
70
+ import { GlobalHeader, type GlobalHeaderIdentity } from "./components/global-header/GlobalHeader";
71
+ import { GlobalHeaderSearch } from "./components/global-header/GlobalHeaderSearch";
72
+ import { type SearchOption } from "./components/search/GlobalSearch";
73
+ import { HeaderUtilities } from "./components/global-header/HeaderUtilities";
74
+
75
+ /** Local drawer type (replaces Cloudscape AppLayoutToolbarProps.Drawer) */
76
+ interface Drawer {
77
+ id: string;
78
+ ariaLabels?: {
79
+ drawerName?: string;
80
+ triggerButton?: string;
81
+ closeButton?: string;
82
+ };
83
+ trigger?: {
84
+ iconName?: string;
85
+ iconSvg?: React.ReactNode;
86
+ };
87
+ content?: React.ReactNode;
88
+ defaultSize?: number;
89
+ }
90
+
91
+ export interface AppShellProps {
92
+ /** The name of the current service, displayed in the side navigation header */
93
+ serviceName: string;
94
+
95
+ /** Main content to render in the content area */
96
+ children: React.ReactNode;
97
+
98
+ /** Platform identity/branding shown in the top-left of the global header */
99
+ identity?: GlobalHeaderIdentity;
100
+
101
+ /** Breadcrumb trail for hierarchical navigation within the service */
102
+ breadcrumbs?: BreadcrumbItem[];
103
+
104
+ /** Navigation items for the left side navigation panel */
105
+ navigationItems?: ReadonlyArray<CompatibleNavigationItem>;
106
+
107
+ /** Link destination when clicking the service name in side navigation */
108
+ navigationHeaderHref?: string;
109
+
110
+ /** Currently active navigation item href for highlighting */
111
+ navigationActiveHref?: string;
112
+
113
+ /** Callback when a navigation item is clicked */
114
+ onNavigationFollow?: (event: CompatibleNavigationFollowEvent) => void;
115
+
116
+ /** Callback when a breadcrumb is clicked */
117
+ onBreadcrumbFollow?: (event: React.MouseEvent, item: BreadcrumbItem) => void;
118
+
119
+ /** Optional header content displayed above the main content */
120
+ contentHeader?: React.ReactNode;
121
+
122
+ /** Tools panel content (right side panel) */
123
+ tools?: React.ReactNode;
124
+
125
+ /** Content for the chatbot/assistant drawer */
126
+ drawerContent?: React.ReactNode;
127
+
128
+ /** Flash notifications to display at the top of the content area */
129
+ notifications?: FlashbarMessage[];
130
+
131
+ /** Whether to disable default content padding */
132
+ disableContentPaddings?: boolean;
133
+
134
+ /** Maximum width of the content area in pixels */
135
+ maxContentWidth?: number;
136
+
137
+ /** Minimum width of the content area in pixels */
138
+ minContentWidth?: number;
139
+
140
+ /**
141
+ * Callback when the user types in the global search bar.
142
+ * Search results should display available services matching the query.
143
+ */
144
+ onSearch?: (value: string) => void;
145
+
146
+ /** Callback when a search result is selected */
147
+ onSearchSelect?: (service: ServiceItem) => void;
148
+
149
+ /** Controlled value for the search input */
150
+ searchValue?: string;
151
+
152
+ /** Placeholder text for the search input */
153
+ searchPlaceholder?: string;
154
+
155
+ /**
156
+ * Menu items for the All Services button (9-dot grid icon).
157
+ * Displays all available platform services and recently visited services.
158
+ */
159
+ appsMenuItems?: MenuDropdownItems;
160
+
161
+ /**
162
+ * Menu items for the Categories button.
163
+ * Displays the available service categories.
164
+ */
165
+ categoriesMenuItems?: MenuDropdownItems;
166
+
167
+ /**
168
+ * Menu items for the Settings dropdown.
169
+ * Contains user preferences, notifications settings, and integrations.
170
+ */
171
+ settingsMenuItems?: MenuDropdownItems;
172
+
173
+ /**
174
+ * Menu items for the User Profile dropdown.
175
+ * Contains account settings, profile info, and sign out option.
176
+ */
177
+ userMenuItems?: MenuDropdownItems;
178
+
179
+ /** Callback when an item in the All Services menu is clicked */
180
+ onAppsMenuItemClick?: MenuItemClickHandler;
181
+
182
+ /** Callback when an item in the Categories menu is clicked */
183
+ onCategoriesMenuItemClick?: MenuItemClickHandler;
184
+
185
+ /** Callback when an item in the Settings menu is clicked */
186
+ onSettingsMenuItemClick?: MenuItemClickHandler;
187
+
188
+ /** Callback when an item in the User menu is clicked */
189
+ onUserMenuItemClick?: MenuItemClickHandler;
190
+
191
+ /** Callback to toggle between light and dark theme */
192
+ onThemeToggle?: () => void;
193
+
194
+ /** Current theme mode */
195
+ theme?: "light" | "dark";
196
+
197
+ /** Show the theme toggle button in the global header */
198
+ showThemeToggle?: boolean;
199
+
200
+ /** Icons to display for theme toggle button in each mode (as React nodes) */
201
+ themeIcons?: {
202
+ light: React.ReactNode;
203
+ dark: React.ReactNode;
204
+ };
205
+
206
+ /** Default open state for left navigation when no persisted state exists */
207
+ defaultNavigationOpen?: boolean;
208
+
209
+ /** Default open state for right drawer when no persisted state exists */
210
+ defaultDrawerOpen?: boolean;
211
+
212
+ /** Default drawer id to open when no persisted state exists */
213
+ defaultDrawerId?: string | null;
214
+ }
215
+
216
+ /** Default placeholder menu items when no items are provided */
217
+ const EMPTY_MENU_ITEMS: MenuDropdownItems = [
218
+ { id: "empty", text: "No items", disabled: true },
219
+ ];
220
+
221
+ const useIsomorphicLayoutEffect =
222
+ typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
223
+
224
+ const THEME_ICON_SUN = (
225
+ <svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" focusable="false" aria-hidden="true">
226
+ <circle cx="8" cy="8" r="3" fill="none" stroke="currentColor" strokeWidth="1.5" />
227
+ <path
228
+ d="M8 1.5v2M8 12.5v2M1.5 8h2M12.5 8h2M3.1 3.1l1.4 1.4M11.5 11.5l1.4 1.4M12.9 3.1l-1.4 1.4M4.5 11.5l-1.4 1.4"
229
+ fill="none"
230
+ stroke="currentColor"
231
+ strokeWidth="1.5"
232
+ strokeLinecap="round"
233
+ />
234
+ </svg>
235
+ );
236
+
237
+ const THEME_ICON_MOON = (
238
+ <svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" focusable="false" aria-hidden="true">
239
+ <path
240
+ d="M11.5 10.5A5.5 5.5 0 0 1 5.5 4.5a5 5 0 1 0 6 6Z"
241
+ fill="none"
242
+ stroke="currentColor"
243
+ strokeWidth="1.5"
244
+ strokeLinecap="round"
245
+ strokeLinejoin="round"
246
+ />
247
+ </svg>
248
+ );
249
+
250
+ type PanelState = {
251
+ version: number;
252
+ navigationOpen: boolean;
253
+ toolsOpen: boolean;
254
+ drawerOpen: boolean;
255
+ drawerId: string | null;
256
+ timestamp: number;
257
+ };
258
+
259
+ const PANEL_STATE_STORAGE_KEY = "app-shell.panelState";
260
+ const PANEL_STATE_VERSION = 1;
261
+
262
+ const isPanelState = (value: unknown): value is PanelState => {
263
+ if (!value || typeof value !== "object") return false;
264
+ const record = value as Record<string, unknown>;
265
+ return (
266
+ typeof record.version === "number" &&
267
+ typeof record.navigationOpen === "boolean" &&
268
+ typeof record.toolsOpen === "boolean" &&
269
+ typeof record.drawerOpen === "boolean" &&
270
+ (typeof record.drawerId === "string" || record.drawerId === null) &&
271
+ typeof record.timestamp === "number"
272
+ );
273
+ };
274
+
275
+ const panelStateStore = {
276
+ read(): PanelState | null {
277
+ if (typeof window === "undefined") return null;
278
+ try {
279
+ const raw = window.localStorage.getItem(PANEL_STATE_STORAGE_KEY);
280
+ if (!raw) return null;
281
+ const parsed = JSON.parse(raw);
282
+ return isPanelState(parsed) ? parsed : null;
283
+ } catch {
284
+ return null;
285
+ }
286
+ },
287
+ write(state: PanelState) {
288
+ if (typeof window === "undefined") return;
289
+ try {
290
+ window.localStorage.setItem(PANEL_STATE_STORAGE_KEY, JSON.stringify(state));
291
+ } catch {
292
+ // Ignore storage failures (private mode, quota, etc.)
293
+ }
294
+ },
295
+ };
296
+
297
+ export function AppShell({
298
+ serviceName,
299
+ children,
300
+ identity,
301
+ breadcrumbs,
302
+ navigationItems,
303
+ navigationHeaderHref,
304
+ navigationActiveHref,
305
+ onNavigationFollow,
306
+ onBreadcrumbFollow,
307
+ contentHeader,
308
+ tools,
309
+ drawerContent,
310
+ notifications,
311
+ disableContentPaddings,
312
+ maxContentWidth,
313
+ minContentWidth,
314
+ onSearch,
315
+ onSearchSelect,
316
+ searchValue,
317
+ searchPlaceholder,
318
+ appsMenuItems,
319
+ categoriesMenuItems,
320
+ settingsMenuItems,
321
+ userMenuItems,
322
+ onAppsMenuItemClick,
323
+ onCategoriesMenuItemClick,
324
+ onSettingsMenuItemClick,
325
+ onUserMenuItemClick,
326
+ onThemeToggle,
327
+ theme = "light",
328
+ showThemeToggle = true,
329
+ themeIcons,
330
+ defaultNavigationOpen = true,
331
+ defaultDrawerOpen = false,
332
+ defaultDrawerId = null,
333
+ }: AppShellProps) {
334
+ const isThemeControlled = Boolean(onThemeToggle);
335
+ const [internalTheme, setInternalTheme] = React.useState<"light" | "dark">(
336
+ theme ?? "light",
337
+ );
338
+ const resolvedTheme = isThemeControlled ? (theme ?? "light") : internalTheme;
339
+
340
+ // Apply theme mode (light/dark) to the document before paint
341
+ useIsomorphicLayoutEffect(() => {
342
+ if (resolvedTheme === "dark") {
343
+ document.documentElement.classList.add("dark");
344
+ } else {
345
+ document.documentElement.classList.remove("dark");
346
+ }
347
+ }, [resolvedTheme]);
348
+
349
+ // Inject global styles to force dark dropdown backgrounds
350
+ // CRITICAL: Only target dropdown portals that are DIRECT children of body
351
+ // to avoid affecting table cells which also use "interior" class patterns
352
+ React.useEffect(() => {
353
+ const styleId = 'app-shell-dropdown-overrides';
354
+ if (document.getElementById(styleId)) return;
355
+
356
+ const style = document.createElement('style');
357
+ style.id = styleId;
358
+ // SCOPED: Use body > div[class*="awsui_dropdown"] to ONLY target dropdown portals
359
+ // NOT body > div[class*="awsui"] which is too broad
360
+ style.textContent = `
361
+ /* Force dark background ONLY on dropdown portals (direct children of body with dropdown class) */
362
+ body > div[class*="awsui_dropdown"] [class*="awsui_dropdown-content-wrapper"],
363
+ body > div[class*="awsui_dropdown"] [class*="awsui_interior"],
364
+ body > div[class*="awsui_dropdown"] [class*="awsui_options-list"],
365
+ body > div[class*="awsui_dropdown"] [class*="awsui_items-list-container"],
366
+ body > div[class*="awsui_dropdown"] ul {
367
+ background-color: #171717 !important;
368
+ background: #171717 !important;
369
+ color: #f5f5f5 !important;
370
+ }
371
+ body > div[class*="awsui_dropdown"] [class*="awsui_option"],
372
+ body > div[class*="awsui_dropdown"] [class*="awsui_item-element"],
373
+ body > div[class*="awsui_dropdown"] li {
374
+ background-color: transparent !important;
375
+ background: transparent !important;
376
+ color: #f5f5f5 !important;
377
+ }
378
+ body > div[class*="awsui_dropdown"] [class*="awsui_option"]:hover,
379
+ body > div[class*="awsui_dropdown"] [class*="awsui_item-element"]:hover,
380
+ body > div[class*="awsui_dropdown"] [class*="awsui_highlighted"],
381
+ body > div[class*="awsui_dropdown"] li:hover {
382
+ background-color: transparent !important;
383
+ background: transparent !important;
384
+ color: #26c6ff !important;
385
+ }
386
+ `;
387
+ document.head.appendChild(style);
388
+
389
+ return () => {
390
+ const el = document.getElementById(styleId);
391
+ if (el) el.remove();
392
+ };
393
+ }, []);
394
+
395
+ // Use MutationObserver to forcibly remove border-radius from dropdown elements
396
+ // CRITICAL: Only target dropdown portals that are DIRECT children of body
397
+ // to avoid affecting table cells which also use similar class patterns
398
+ React.useEffect(() => {
399
+ // Helper to check if element is inside a dropdown portal that's a DIRECT child of body
400
+ const isInsideBodyDropdownPortal = (element: Element): boolean => {
401
+ let current: Element | null = element;
402
+ while (current && current !== document.body) {
403
+ const parentElement: Element | null = current.parentElement;
404
+ if (parentElement === document.body) {
405
+ // current is a direct child of body - check if it's a dropdown portal
406
+ const className = current.className || "";
407
+ // Only match if the direct body child has "dropdown" in its class
408
+ return className.includes("awsui") && className.includes("dropdown");
409
+ }
410
+ current = parentElement;
411
+ }
412
+ return false;
413
+ };
414
+
415
+ const styleDropdownElement = (element: Element) => {
416
+ if (!(element instanceof HTMLElement)) return;
417
+
418
+ // CRITICAL: Only process elements inside dropdown portals that are direct children of body
419
+ // This prevents styling table cells which are NOT direct children of body
420
+ if (!isInsideBodyDropdownPortal(element)) return;
421
+
422
+ const className = element.className || "";
423
+
424
+ // Remove border-radius from elements inside dropdown portal
425
+ element.style.setProperty('border-radius', '0', 'important');
426
+
427
+ // Force dark background for dropdown containers
428
+ if (className.includes("content-wrapper") ||
429
+ className.includes("interior") ||
430
+ className.includes("options-list") ||
431
+ className.includes("items-list")) {
432
+ element.style.setProperty('background-color', '#171717', 'important');
433
+ element.style.setProperty('color', '#f5f5f5', 'important');
434
+ element.style.setProperty('border', 'none', 'important');
435
+ }
436
+
437
+ // Force dark background on ul elements
438
+ if (element.tagName === "UL") {
439
+ element.style.setProperty('background-color', '#171717', 'important');
440
+ element.style.setProperty('color', '#f5f5f5', 'important');
441
+ }
442
+ };
443
+
444
+ const observer = new MutationObserver((mutations) => {
445
+ mutations.forEach((mutation) => {
446
+ mutation.addedNodes.forEach((node) => {
447
+ if (node instanceof Element) {
448
+ // Only process if element is a body > div dropdown portal or inside one
449
+ if (node.parentElement === document.body) {
450
+ const className = node.className || "";
451
+ if (className.includes("awsui") && className.includes("dropdown")) {
452
+ styleDropdownElement(node);
453
+ node.querySelectorAll("*").forEach(styleDropdownElement);
454
+ }
455
+ } else if (isInsideBodyDropdownPortal(node)) {
456
+ styleDropdownElement(node);
457
+ node.querySelectorAll("*").forEach(styleDropdownElement);
458
+ }
459
+ }
460
+ });
461
+ });
462
+ });
463
+
464
+ // Only observe direct children of body for efficiency
465
+ observer.observe(document.body, {
466
+ childList: true,
467
+ subtree: true,
468
+ });
469
+
470
+ // Process any existing dropdown portals that are direct children of body
471
+ // Note: Use document.body.querySelectorAll so :scope resolves to <body>, not <html>
472
+ document.body.querySelectorAll(':scope > div[class*="awsui"][class*="dropdown"]').forEach((portal) => {
473
+ styleDropdownElement(portal);
474
+ portal.querySelectorAll("*").forEach(styleDropdownElement);
475
+ });
476
+
477
+ return () => observer.disconnect();
478
+ }, []);
479
+
480
+ // State for collapsible panels (hydrated from persisted storage when available)
481
+ const fallbackDrawerId = defaultDrawerId ?? (drawerContent ? "chatbot" : tools ? "tools" : null);
482
+ const initialPanelState = React.useMemo(() => {
483
+ const stored = panelStateStore.read();
484
+ return {
485
+ navigationOpen: stored?.navigationOpen ?? defaultNavigationOpen,
486
+ toolsOpen: stored?.toolsOpen ?? false,
487
+ drawerOpen: stored?.drawerOpen ?? defaultDrawerOpen,
488
+ drawerId: stored?.drawerId ?? fallbackDrawerId,
489
+ };
490
+ }, [defaultNavigationOpen, defaultDrawerOpen, fallbackDrawerId]);
491
+
492
+ const [navigationOpen, setNavigationOpen] = React.useState(
493
+ initialPanelState.navigationOpen,
494
+ );
495
+ const [toolsOpen, setToolsOpen] = React.useState(
496
+ initialPanelState.toolsOpen,
497
+ );
498
+ const [activeDrawerId, setActiveDrawerId] = React.useState<string | null>(
499
+ initialPanelState.drawerOpen ? initialPanelState.drawerId ?? null : null,
500
+ );
501
+ const lastDrawerIdRef = React.useRef<string | null>(
502
+ initialPanelState.drawerId ?? fallbackDrawerId,
503
+ );
504
+
505
+ React.useEffect(() => {
506
+ if (activeDrawerId) {
507
+ lastDrawerIdRef.current = activeDrawerId;
508
+ }
509
+ }, [activeDrawerId]);
510
+
511
+ const [serviceCatalog, setServiceCatalog] = React.useState(() =>
512
+ getServiceCatalogSync(),
513
+ );
514
+
515
+ React.useEffect(() => {
516
+ if (!serviceCatalog) {
517
+ getServiceCatalog()
518
+ .then((catalog) => setServiceCatalog(catalog))
519
+ .catch(() => {
520
+ // Swallow catalog loading errors for now (mock data only).
521
+ });
522
+ }
523
+ }, [serviceCatalog]);
524
+
525
+ // Internal search state for uncontrolled mode
526
+ const [internalSearch, setInternalSearch] = React.useState("");
527
+
528
+ // Support both controlled and uncontrolled search input
529
+ const isSearchControlled = typeof searchValue === "string";
530
+ const searchInputValue = isSearchControlled ? searchValue : internalSearch;
531
+
532
+ const serviceGroups = React.useMemo(
533
+ () => (serviceCatalog ? groupServicesByCategory(serviceCatalog) : []),
534
+ [serviceCatalog],
535
+ );
536
+
537
+ const serviceById = React.useMemo(() => {
538
+ if (!serviceCatalog) return new Map<string, ServiceItem>();
539
+ return new Map(serviceCatalog.services.map((service) => [service.id, service]));
540
+ }, [serviceCatalog]);
541
+
542
+ const categoryLabels = React.useMemo(() => {
543
+ if (!serviceCatalog) return new Map<string, string>();
544
+ return new Map(
545
+ serviceCatalog.categories.map((category) => [category.id, category.label]),
546
+ );
547
+ }, [serviceCatalog]);
548
+
549
+ const catalogMenuItems = React.useMemo<MenuDropdownItems>(() => {
550
+ if (!serviceCatalog) {
551
+ return [{ id: "loading", text: "Loading services", disabled: true }];
552
+ }
553
+
554
+ return serviceGroups.map((group) => ({
555
+ type: "group",
556
+ text: group.category.label,
557
+ items: group.services.map((service) => ({
558
+ id: service.id,
559
+ text: service.name,
560
+ description: service.summary,
561
+ href: service.href,
562
+ })),
563
+ }));
564
+ }, [serviceCatalog, serviceGroups]);
565
+
566
+ const catalogCategoryMenuItems = React.useMemo<MenuDropdownItems>(() => {
567
+ if (!serviceCatalog) {
568
+ return [{ id: "loading", text: "Loading categories", disabled: true }];
569
+ }
570
+
571
+ const categoryHrefById = new Map<string, string>();
572
+
573
+ for (const group of serviceGroups) {
574
+ const serviceHref = group.services[0]?.href;
575
+ if (serviceHref && serviceHref.startsWith("/")) {
576
+ const rootSegment = serviceHref.split("/").filter(Boolean)[0];
577
+ if (rootSegment) {
578
+ categoryHrefById.set(group.category.id, `/${rootSegment}/`);
579
+ }
580
+ }
581
+ }
582
+
583
+ return serviceCatalog.categories.map((category) => ({
584
+ id: category.id,
585
+ text: category.label,
586
+ description: category.description,
587
+ href: categoryHrefById.get(category.id),
588
+ }));
589
+ }, [serviceCatalog, serviceGroups]);
590
+
591
+ const hasSearchQuery = searchInputValue.trim().length > 0;
592
+
593
+ const searchResults = React.useMemo(() => {
594
+ if (!serviceCatalog || !hasSearchQuery) return [];
595
+ return searchServices(serviceCatalog, searchInputValue);
596
+ }, [serviceCatalog, searchInputValue, hasSearchQuery]);
597
+
598
+ const searchOptions = React.useMemo<SearchOption[]>(() => {
599
+ if (!serviceCatalog || !hasSearchQuery) return [];
600
+ return searchResults.map((service) => ({
601
+ value: service.id,
602
+ label: service.name,
603
+ description: service.summary,
604
+ tags: [categoryLabels.get(service.categoryId) ?? "Service"],
605
+ }));
606
+ }, [serviceCatalog, searchResults, categoryLabels, hasSearchQuery]);
607
+
608
+ const handleSearchValueChange = (value: string) => {
609
+ if (!isSearchControlled) {
610
+ setInternalSearch(value);
611
+ }
612
+ onSearch?.(value);
613
+ };
614
+
615
+ const handleSearchSelect = (option: SearchOption) => {
616
+ const selectedService = serviceById.get(option.value);
617
+ if (!selectedService) {
618
+ return;
619
+ }
620
+
621
+ if (!isSearchControlled) {
622
+ setInternalSearch(selectedService.name);
623
+ }
624
+
625
+ onSearch?.(selectedService.name);
626
+
627
+ if (onSearchSelect) {
628
+ onSearchSelect(selectedService);
629
+ return;
630
+ }
631
+
632
+ if (selectedService.href) {
633
+ window.location.assign(selectedService.href);
634
+ }
635
+ };
636
+
637
+ /*
638
+ * Side Navigation Panel
639
+ * ---------------------
640
+ * Left-side collapsible panel containing service-specific navigation.
641
+ * Shows the service name in the header and hierarchical navigation items.
642
+ */
643
+ // Cast to internal type - both Cloudscape and custom types share the same shape
644
+ const normalizedNavigationItems = (navigationItems ?? []) as ReadonlyArray<NavigationItem>;
645
+
646
+ // Adapter to normalize Cloudscape CustomEvent to our NavigationFollowEvent
647
+ const handleNavigationFollow = onNavigationFollow
648
+ ? (event: NavigationFollowEvent) => {
649
+ onNavigationFollow(event as CompatibleNavigationFollowEvent);
650
+ }
651
+ : undefined;
652
+
653
+ const navigation = (
654
+ <AppNavigation
655
+ serviceName={serviceName}
656
+ headerHref={navigationHeaderHref ?? "#"}
657
+ items={normalizedNavigationItems}
658
+ activeHref={navigationActiveHref}
659
+ onFollow={handleNavigationFollow}
660
+ onClose={() => setNavigationOpen(false)}
661
+ basePath={basePath()}
662
+ />
663
+ );
664
+
665
+ // Breadcrumb navigation for hierarchical page structure
666
+ const breadcrumbsItems: BreadcrumbItem[] = breadcrumbs ?? [];
667
+ const breadcrumbsNode = breadcrumbsItems.length ? (
668
+ <AppBreadcrumb items={breadcrumbsItems} onFollow={onBreadcrumbFollow} />
669
+ ) : undefined;
670
+
671
+ const resolvedAppsMenuItems = appsMenuItems ?? catalogMenuItems ?? EMPTY_MENU_ITEMS;
672
+ const resolvedCategoriesMenuItems =
673
+ categoriesMenuItems ?? catalogCategoryMenuItems ?? EMPTY_MENU_ITEMS;
674
+ const resolvedIdentity =
675
+ identity ?? {
676
+ logo: {
677
+ src: "https://reactrouter.com/_brand/React%20Router%20Brand%20Assets/React%20Router%20Lockup/Dark.svg",
678
+ alt: "Untitled",
679
+ },
680
+ href: "/",
681
+ };
682
+
683
+ /*
684
+ * Top Navigation Utilities (Right Side)
685
+ * -------------------------------------
686
+ * Utility buttons displayed on the right side of the global header:
687
+ * 1. Settings - User preferences, notifications, integrations
688
+ * 2. Theme Toggle - Switch between light and dark mode (optional)
689
+ * 3. User Profile - Account settings, profile, sign out
690
+ */
691
+ const handleThemeToggle = onThemeToggle ?? (() => {
692
+ setInternalTheme((prev) => (prev === "dark" ? "light" : "dark"));
693
+ });
694
+
695
+ // Pass through the click handlers directly - they now use the same signature
696
+ const handleSettingsItemClick = onSettingsMenuItemClick;
697
+ const handleUserItemClick = onUserMenuItemClick;
698
+
699
+ const utilitiesContent = (
700
+ <HeaderUtilities
701
+ settingsItems={settingsMenuItems ?? EMPTY_MENU_ITEMS}
702
+ onSettingsItemClick={handleSettingsItemClick}
703
+ userItems={userMenuItems ?? EMPTY_MENU_ITEMS}
704
+ onUserItemClick={handleUserItemClick}
705
+ showThemeToggle={showThemeToggle}
706
+ theme={resolvedTheme}
707
+ onThemeToggle={handleThemeToggle}
708
+ themeIcons={themeIcons ?? {
709
+ light: THEME_ICON_SUN,
710
+ dark: THEME_ICON_MOON,
711
+ }}
712
+ />
713
+ );
714
+
715
+ /*
716
+ * Search Area Content (Center of Global Header)
717
+ * ----------------------------------------------
718
+ * Contains:
719
+ * 1. Visual separator (|)
720
+ * 2. Services Menu (9-dot grid) - Combined dropdown with categories on the
721
+ * left and all services on the right for quick navigation
722
+ * 3. Global Search Bar - Allows users to search for services, features,
723
+ * or content across the platform
724
+ */
725
+ const searchContent = (
726
+ <GlobalHeaderSearch
727
+ appsMenuItems={resolvedAppsMenuItems}
728
+ onAppsMenuItemClick={onAppsMenuItemClick}
729
+ categoriesMenuItems={resolvedCategoriesMenuItems}
730
+ onCategoriesMenuItemClick={onCategoriesMenuItemClick}
731
+ searchValue={searchInputValue}
732
+ onSearchChange={handleSearchValueChange}
733
+ onSearchSelect={handleSearchSelect}
734
+ searchPlaceholder={searchPlaceholder ?? "Search"}
735
+ searchOptions={searchOptions}
736
+ empty={hasSearchQuery ? "No matches" : undefined}
737
+ loading={!serviceCatalog && hasSearchQuery}
738
+ />
739
+ );
740
+
741
+ const drawers: Drawer[] = [];
742
+
743
+ if (tools) {
744
+ drawers.push({
745
+ id: "tools",
746
+ ariaLabels: {
747
+ drawerName: "Tools",
748
+ triggerButton: "Open tools",
749
+ closeButton: "Close tools",
750
+ },
751
+ trigger: { iconName: "settings" },
752
+ content: tools,
753
+ });
754
+ }
755
+
756
+ if (drawerContent) {
757
+ drawers.push({
758
+ id: "chatbot",
759
+ ariaLabels: {
760
+ drawerName: "Chatbot",
761
+ triggerButton: "Open chatbot",
762
+ closeButton: "Close chatbot",
763
+ },
764
+ defaultSize: 420,
765
+ trigger: {
766
+ iconSvg: (
767
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="white">
768
+ <path d="M20 2H4C2.9 2 2 2.9 2 4V16C2 17.1 2.9 18 4 18H18L22 22V4C22 2.9 21.1 2 20 2Z" />
769
+ </svg>
770
+ ),
771
+ },
772
+ content: activeDrawerId === "chatbot" ? drawerContent : null,
773
+ });
774
+ }
775
+
776
+
777
+
778
+ React.useEffect(() => {
779
+ if (!activeDrawerId) return;
780
+ const isValidDrawer = drawers.some((drawer) => drawer.id === activeDrawerId);
781
+ if (!isValidDrawer) {
782
+ setActiveDrawerId(null);
783
+ }
784
+ }, [activeDrawerId, drawers]);
785
+
786
+ React.useEffect(() => {
787
+ const drawerIdForStorage = activeDrawerId ?? lastDrawerIdRef.current ?? null;
788
+ panelStateStore.write({
789
+ version: PANEL_STATE_VERSION,
790
+ navigationOpen,
791
+ toolsOpen,
792
+ drawerOpen: Boolean(activeDrawerId),
793
+ drawerId: drawerIdForStorage,
794
+ timestamp: Date.now(),
795
+ });
796
+ }, [navigationOpen, toolsOpen, activeDrawerId]);
797
+
798
+ return (
799
+ // suppressHydrationWarning is needed because browser extensions (Dashlane, 1Password, etc.)
800
+ // inject data attributes before React hydrates, causing hydration mismatches
801
+ <div className="app-shell" suppressHydrationWarning>
802
+ {/*
803
+ * Global Header
804
+ * -------------
805
+ * Sticky header at the top of the page containing:
806
+ * - Platform identity/branding (left)
807
+ * - All Services button and search bar (center)
808
+ * - Settings, theme toggle, and user profile (right)
809
+ */}
810
+ <GlobalHeader
811
+ identity={resolvedIdentity}
812
+ search={searchContent}
813
+ utilities={utilitiesContent}
814
+ />
815
+
816
+ {/*
817
+ * Main Application Layout
818
+ * -----------------------
819
+ * Custom layout with shadcn Sidebar:
820
+ * - Collapsible side navigation (left)
821
+ * - Main content area (center)
822
+ */}
823
+ <AppLayout
824
+ navigation={navigation}
825
+ navigationOpen={navigationOpen}
826
+ onNavigationChange={setNavigationOpen}
827
+ tools={tools}
828
+ toolsOpen={toolsOpen}
829
+ onToolsChange={setToolsOpen}
830
+ copilot={drawerContent}
831
+ copilotOpen={activeDrawerId === "chatbot"}
832
+ onCopilotChange={(open) => {
833
+ setActiveDrawerId(open ? "chatbot" : null);
834
+ }}
835
+ copilotHide={!drawerContent}
836
+ breadcrumbs={breadcrumbsNode}
837
+ notifications={notifications ? <AppFlashbar items={notifications} /> : undefined}
838
+ contentHeader={contentHeader}
839
+ disableContentPaddings={disableContentPaddings}
840
+ maxContentWidth={maxContentWidth}
841
+ minContentWidth={minContentWidth}
842
+ >
843
+ {children}
844
+ </AppLayout>
845
+ </div>
846
+ );
847
+ }