@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
package/src/AppShell.tsx
ADDED
|
@@ -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
|
+
}
|