@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,57 @@
1
+ import React from "react";
2
+
3
+ import { GlobalSearch, type SearchOption } from "../search/GlobalSearch";
4
+ import {
5
+ ServicesMenu,
6
+ type MenuDropdownItems,
7
+ type MenuItemClickHandler,
8
+ } from "./ServicesMenu";
9
+
10
+ export type GlobalHeaderSearchProps = {
11
+ appsMenuItems: MenuDropdownItems;
12
+ onAppsMenuItemClick?: MenuItemClickHandler;
13
+ categoriesMenuItems: MenuDropdownItems;
14
+ onCategoriesMenuItemClick?: MenuItemClickHandler;
15
+ searchValue: string;
16
+ onSearchChange: (value: string) => void;
17
+ onSearchSelect: (option: SearchOption) => void;
18
+ searchPlaceholder?: string;
19
+ searchOptions: SearchOption[];
20
+ empty?: React.ReactNode;
21
+ loading?: boolean;
22
+ };
23
+
24
+ export function GlobalHeaderSearch({
25
+ appsMenuItems,
26
+ onAppsMenuItemClick,
27
+ categoriesMenuItems,
28
+ onCategoriesMenuItemClick,
29
+ searchValue,
30
+ onSearchChange,
31
+ onSearchSelect,
32
+ searchPlaceholder,
33
+ searchOptions,
34
+ empty,
35
+ loading,
36
+ }: GlobalHeaderSearchProps) {
37
+ return (
38
+ <div className="app-shell-search app-shell-header-search">
39
+ <span className="app-shell-separator">|</span>
40
+ <ServicesMenu
41
+ appsMenuItems={appsMenuItems}
42
+ categoriesMenuItems={categoriesMenuItems}
43
+ onAppsMenuItemClick={onAppsMenuItemClick}
44
+ onCategoriesMenuItemClick={onCategoriesMenuItemClick}
45
+ />
46
+ <GlobalSearch
47
+ value={searchValue}
48
+ onChange={onSearchChange}
49
+ onSelect={onSearchSelect}
50
+ placeholder={searchPlaceholder}
51
+ options={searchOptions}
52
+ empty={empty}
53
+ loading={loading}
54
+ />
55
+ </div>
56
+ );
57
+ }
@@ -0,0 +1,243 @@
1
+ import React from "react";
2
+
3
+ import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
4
+ import { Settings2 } from "lucide-react";
5
+
6
+ import type {
7
+ MenuDropdownItems,
8
+ MenuDropdownItem,
9
+ MenuDropdownItemGroup,
10
+ MenuItemClickDetail,
11
+ } from "./ServicesMenu";
12
+
13
+ const UserIcon = () => (
14
+ <svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
15
+ <path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z" />
16
+ <path d="M8 9a5 5 0 0 0-5 5v.5a.5.5 0 0 0 1 0V14a4 4 0 0 1 8 0v.5a.5.5 0 0 0 1 0V14a5 5 0 0 0-5-5Z" />
17
+ </svg>
18
+ );
19
+
20
+ const ChevronDownIcon = () => (
21
+ <svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
22
+ <path d="M4.427 6.427a.75.75 0 0 1 1.06 0L8 8.94l2.513-2.513a.75.75 0 1 1 1.06 1.06l-3.043 3.043a.75.75 0 0 1-1.06 0L4.427 7.487a.75.75 0 0 1 0-1.06Z" />
23
+ </svg>
24
+ );
25
+
26
+ export interface HeaderMenuDropdownProps {
27
+ icon: React.ReactNode;
28
+ label?: string;
29
+ ariaLabel: string;
30
+ items: MenuDropdownItems;
31
+ onItemClick?: (detail: MenuItemClickDetail) => void;
32
+ }
33
+
34
+ type MenuItem = MenuDropdownItem | MenuDropdownItemGroup;
35
+
36
+ const isGroup = (item: MenuItem): item is MenuDropdownItemGroup =>
37
+ typeof (item as MenuDropdownItemGroup).items !== "undefined";
38
+
39
+ export function HeaderMenuDropdown({
40
+ icon,
41
+ label,
42
+ ariaLabel,
43
+ items,
44
+ onItemClick,
45
+ }: HeaderMenuDropdownProps) {
46
+ const [open, setOpen] = React.useState(false);
47
+
48
+ const handleItemClick = (item: MenuDropdownItem) => () => {
49
+ if (item.disabled) return;
50
+
51
+ onItemClick?.({
52
+ id: item.id,
53
+ href: item.href,
54
+ external: item.external,
55
+ });
56
+
57
+ setOpen(false);
58
+ };
59
+
60
+ const renderItems = (menuItems: MenuDropdownItems) => {
61
+ return menuItems.map((item, index) => {
62
+ if (isGroup(item)) {
63
+ return (
64
+ <React.Fragment key={`group-${index}`}>
65
+ {item.text && (
66
+ <DropdownMenu.Label className="app-shell-header-dropdown-label">
67
+ {item.text}
68
+ </DropdownMenu.Label>
69
+ )}
70
+ {item.items.map((subItem) => renderItem(subItem))}
71
+ {index < menuItems.length - 1 && (
72
+ <DropdownMenu.Separator className="app-shell-header-dropdown-separator" />
73
+ )}
74
+ </React.Fragment>
75
+ );
76
+ }
77
+ return renderItem(item as MenuDropdownItem);
78
+ });
79
+ };
80
+
81
+ const renderItem = (item: MenuDropdownItem) => {
82
+ const content = (
83
+ <>
84
+ <span className="app-shell-header-dropdown-item-text">{item.text}</span>
85
+ {item.description && (
86
+ <span className="app-shell-header-dropdown-item-description">
87
+ {item.description}
88
+ </span>
89
+ )}
90
+ </>
91
+ );
92
+
93
+ if (item.href) {
94
+ return (
95
+ <DropdownMenu.Item key={item.id} asChild disabled={item.disabled}>
96
+ <a
97
+ href={item.href}
98
+ target={item.external ? "_blank" : undefined}
99
+ rel={item.external ? "noopener noreferrer" : undefined}
100
+ className="app-shell-header-dropdown-item"
101
+ onClick={handleItemClick(item)}
102
+ >
103
+ {content}
104
+ </a>
105
+ </DropdownMenu.Item>
106
+ );
107
+ }
108
+
109
+ return (
110
+ <DropdownMenu.Item
111
+ key={item.id}
112
+ disabled={item.disabled}
113
+ className="app-shell-header-dropdown-item"
114
+ onSelect={handleItemClick(item)}
115
+ >
116
+ {content}
117
+ </DropdownMenu.Item>
118
+ );
119
+ };
120
+
121
+ return (
122
+ <DropdownMenu.Root open={open} onOpenChange={setOpen}>
123
+ <DropdownMenu.Trigger asChild>
124
+ {/* suppressHydrationWarning: browser extensions inject attributes before hydration */}
125
+ <button
126
+ type="button"
127
+ className={label ? "app-shell-header-menu-button" : "app-shell-header-utility-button"}
128
+ aria-label={ariaLabel}
129
+ suppressHydrationWarning
130
+ >
131
+ <span className="app-shell-header-menu-icon">{icon}</span>
132
+ {label && <span className="app-shell-header-menu-label">{label}</span>}
133
+ {label && <span className="app-shell-header-menu-chevron"><ChevronDownIcon /></span>}
134
+ </button>
135
+ </DropdownMenu.Trigger>
136
+
137
+ <DropdownMenu.Portal>
138
+ <DropdownMenu.Content
139
+ className="app-shell-header-dropdown"
140
+ sideOffset={8}
141
+ align="end"
142
+ >
143
+ {renderItems(items)}
144
+ </DropdownMenu.Content>
145
+ </DropdownMenu.Portal>
146
+ </DropdownMenu.Root>
147
+ );
148
+ }
149
+
150
+ export interface HeaderUtilitiesProps {
151
+ settingsItems?: MenuDropdownItems;
152
+ settingsLabel?: string;
153
+ onSettingsItemClick?: (detail: MenuItemClickDetail) => void;
154
+ userItems?: MenuDropdownItems;
155
+ userLabel?: string;
156
+ onUserItemClick?: (detail: MenuItemClickDetail) => void;
157
+ showThemeToggle?: boolean;
158
+ theme?: "light" | "dark";
159
+ onThemeToggle?: () => void;
160
+ themeIcons?: {
161
+ light: React.ReactNode;
162
+ dark: React.ReactNode;
163
+ };
164
+ }
165
+
166
+ export function HeaderUtilities({
167
+ settingsItems,
168
+ settingsLabel = "Settings",
169
+ onSettingsItemClick,
170
+ userItems,
171
+ userLabel = "Profile",
172
+ onUserItemClick,
173
+ showThemeToggle = true,
174
+ theme = "light",
175
+ onThemeToggle,
176
+ themeIcons,
177
+ }: HeaderUtilitiesProps) {
178
+ const defaultLightIcon = (
179
+ <svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
180
+ <circle cx="8" cy="8" r="3" fill="none" stroke="currentColor" strokeWidth="1.5" />
181
+ <path
182
+ 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"
183
+ fill="none"
184
+ stroke="currentColor"
185
+ strokeWidth="1.5"
186
+ strokeLinecap="round"
187
+ />
188
+ </svg>
189
+ );
190
+
191
+ const defaultDarkIcon = (
192
+ <svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
193
+ <path
194
+ d="M11.5 10.5A5.5 5.5 0 0 1 5.5 4.5a5 5 0 1 0 6 6Z"
195
+ fill="none"
196
+ stroke="currentColor"
197
+ strokeWidth="1.5"
198
+ strokeLinecap="round"
199
+ strokeLinejoin="round"
200
+ />
201
+ </svg>
202
+ );
203
+
204
+ const sunIcon = themeIcons?.light ?? defaultLightIcon;
205
+ const moonIcon = themeIcons?.dark ?? defaultDarkIcon;
206
+
207
+ return (
208
+ <>
209
+ {settingsItems && settingsItems.length > 0 && (
210
+ <HeaderMenuDropdown
211
+ icon={<Settings2 className="h-5 w-5" />}
212
+ label={settingsLabel}
213
+ ariaLabel="Settings"
214
+ items={settingsItems}
215
+ onItemClick={onSettingsItemClick}
216
+ />
217
+ )}
218
+
219
+ {showThemeToggle && (
220
+ // suppressHydrationWarning: browser extensions inject attributes before hydration
221
+ <button
222
+ type="button"
223
+ className="app-shell-header-utility-button"
224
+ aria-label="Toggle theme"
225
+ onClick={onThemeToggle}
226
+ suppressHydrationWarning
227
+ >
228
+ {theme === "dark" ? sunIcon : moonIcon}
229
+ </button>
230
+ )}
231
+
232
+ {userItems && userItems.length > 0 && (
233
+ <HeaderMenuDropdown
234
+ icon={<UserIcon />}
235
+ label={userLabel}
236
+ ariaLabel="User menu"
237
+ items={userItems}
238
+ onItemClick={onUserItemClick}
239
+ />
240
+ )}
241
+ </>
242
+ );
243
+ }
@@ -0,0 +1,246 @@
1
+ import { useState, useMemo, type MouseEvent } from "react";
2
+
3
+ import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
4
+
5
+ // Custom types to replace Cloudscape ButtonDropdownProps
6
+ export interface MenuDropdownItem {
7
+ id: string;
8
+ text: string;
9
+ description?: string;
10
+ href?: string;
11
+ external?: boolean;
12
+ disabled?: boolean;
13
+ }
14
+
15
+ export interface MenuDropdownItemGroup {
16
+ type?: "group";
17
+ text?: string;
18
+ items: readonly MenuDropdownItem[];
19
+ }
20
+
21
+ export type MenuDropdownItemOrGroup = MenuDropdownItem | MenuDropdownItemGroup;
22
+ export type MenuDropdownItems = readonly MenuDropdownItemOrGroup[];
23
+
24
+ export interface MenuItemClickDetail {
25
+ id: string;
26
+ href?: string;
27
+ external?: boolean;
28
+ }
29
+
30
+ export type MenuItemClickHandler = (detail: MenuItemClickDetail) => void;
31
+
32
+ export type ServicesMenuProps = {
33
+ appsMenuItems: MenuDropdownItems;
34
+ categoriesMenuItems: MenuDropdownItems;
35
+ onAppsMenuItemClick?: MenuItemClickHandler;
36
+ onCategoriesMenuItemClick?: MenuItemClickHandler;
37
+ };
38
+
39
+ type MenuItem = MenuDropdownItemOrGroup;
40
+
41
+ const DotsNineIcon = () => (
42
+ <svg
43
+ aria-hidden="true"
44
+ focusable="false"
45
+ width="24"
46
+ height="24"
47
+ viewBox="0 0 24 24"
48
+ >
49
+ {([5, 12, 19] as const).flatMap((cx) =>
50
+ ([5, 12, 19] as const).map((cy) => (
51
+ <circle key={`${cx}-${cy}`} cx={cx} cy={cy} r={1.6} fill="currentColor" />
52
+ )),
53
+ )}
54
+ </svg>
55
+ );
56
+
57
+ const isGroup = (item: MenuItem): item is MenuDropdownItemGroup =>
58
+ typeof (item as MenuDropdownItemGroup).items !== "undefined";
59
+
60
+ const flattenItems = (items: MenuDropdownItems): MenuDropdownItem[] => {
61
+ const result: MenuDropdownItem[] = [];
62
+
63
+ items.forEach((item) => {
64
+ if (isGroup(item)) {
65
+ result.push(...flattenItems(item.items));
66
+ } else {
67
+ result.push(item as MenuDropdownItem);
68
+ }
69
+ });
70
+
71
+ return result;
72
+ };
73
+
74
+ const normalizeGroups = (
75
+ items: MenuDropdownItems,
76
+ ): Array<{ title?: string; items: MenuDropdownItem[] }> =>
77
+ items.map((item) => {
78
+ if (isGroup(item)) {
79
+ return { title: item.text, items: flattenItems(item.items) };
80
+ }
81
+
82
+ return { title: undefined, items: [item as MenuDropdownItem] };
83
+ });
84
+
85
+ export function ServicesMenu({
86
+ appsMenuItems,
87
+ categoriesMenuItems,
88
+ onAppsMenuItemClick,
89
+ onCategoriesMenuItemClick,
90
+ }: ServicesMenuProps) {
91
+ const [open, setOpen] = useState(false);
92
+ const categories = useMemo(
93
+ () => flattenItems(categoriesMenuItems),
94
+ [categoriesMenuItems],
95
+ );
96
+ const groups = useMemo(
97
+ () => normalizeGroups(appsMenuItems),
98
+ [appsMenuItems],
99
+ );
100
+
101
+ const handleItemClick =
102
+ (item: MenuDropdownItem) => (event: MouseEvent<HTMLElement>) => {
103
+ if (item.disabled) {
104
+ event.preventDefault();
105
+ event.stopPropagation();
106
+ return;
107
+ }
108
+
109
+ onAppsMenuItemClick?.({
110
+ id: item.id,
111
+ href: item.href,
112
+ external: item.external,
113
+ });
114
+
115
+ setOpen(false);
116
+ };
117
+
118
+ const handleCategoryClick =
119
+ (item: MenuDropdownItem) => (event: MouseEvent<HTMLElement>) => {
120
+ if (item.disabled) {
121
+ event.preventDefault();
122
+ event.stopPropagation();
123
+ return;
124
+ }
125
+
126
+ if (onCategoriesMenuItemClick) {
127
+ event.preventDefault();
128
+ onCategoriesMenuItemClick({
129
+ id: item.id,
130
+ href: item.href,
131
+ external: item.external,
132
+ });
133
+ }
134
+
135
+ setOpen(false);
136
+ };
137
+
138
+ return (
139
+ <DropdownMenu.Root open={open} onOpenChange={setOpen}>
140
+ <DropdownMenu.Trigger asChild>
141
+ <button type="button" aria-label="All services" className="app-shell-apps-button">
142
+ <span className="app-shell-apps-icon">
143
+ <DotsNineIcon />
144
+ </span>
145
+ </button>
146
+ </DropdownMenu.Trigger>
147
+
148
+ <DropdownMenu.Portal>
149
+ <DropdownMenu.Content
150
+ className="app-shell-services-dropdown"
151
+ sideOffset={8}
152
+ align="start"
153
+ avoidCollisions={false}
154
+ >
155
+ <div className="app-shell-services-menu" role="menu">
156
+ <div className="app-shell-services-categories" role="presentation">
157
+ <div className="app-shell-services-categories-title">Categories</div>
158
+ <ul>
159
+ {categories.map((category) => (
160
+ <li key={category.id}>
161
+ {category.href ? (
162
+ <a
163
+ className="app-shell-services-category"
164
+ href={category.href}
165
+ target={category.external ? "_blank" : undefined}
166
+ rel={category.external ? "noopener noreferrer" : undefined}
167
+ aria-disabled={category.disabled || undefined}
168
+ onClick={handleCategoryClick(category)}
169
+ >
170
+ {category.text}
171
+ </a>
172
+ ) : (
173
+ <button
174
+ type="button"
175
+ className="app-shell-services-category"
176
+ disabled={category.disabled}
177
+ onClick={handleCategoryClick(category)}
178
+ >
179
+ {category.text}
180
+ </button>
181
+ )}
182
+ </li>
183
+ ))}
184
+ </ul>
185
+ </div>
186
+ <div className="app-shell-services-divider" aria-hidden="true" />
187
+ <div className="app-shell-services-list" role="presentation">
188
+ {groups.map((group, groupIndex) => (
189
+ <div className="app-shell-services-group" key={`${group.title}-${groupIndex}`}>
190
+ {group.title ? (
191
+ <div className="app-shell-services-group-title">{group.title}</div>
192
+ ) : null}
193
+ <ul>
194
+ {group.items.map((item) => {
195
+ const isDisabled = Boolean(item.disabled);
196
+ const content = (
197
+ <>
198
+ <span className="app-shell-services-item-text">{item.text}</span>
199
+ {item.description ? (
200
+ <span className="app-shell-services-item-description">
201
+ {item.description}
202
+ </span>
203
+ ) : null}
204
+ </>
205
+ );
206
+
207
+ if (item.href) {
208
+ return (
209
+ <li key={item.id}>
210
+ <a
211
+ className="app-shell-services-item"
212
+ href={item.href}
213
+ target={item.external ? "_blank" : undefined}
214
+ rel={item.external ? "noopener noreferrer" : undefined}
215
+ aria-disabled={isDisabled || undefined}
216
+ onClick={handleItemClick(item)}
217
+ >
218
+ {content}
219
+ </a>
220
+ </li>
221
+ );
222
+ }
223
+
224
+ return (
225
+ <li key={item.id}>
226
+ <button
227
+ type="button"
228
+ className="app-shell-services-item"
229
+ disabled={isDisabled}
230
+ onClick={handleItemClick(item)}
231
+ >
232
+ {content}
233
+ </button>
234
+ </li>
235
+ );
236
+ })}
237
+ </ul>
238
+ </div>
239
+ ))}
240
+ </div>
241
+ </div>
242
+ </DropdownMenu.Content>
243
+ </DropdownMenu.Portal>
244
+ </DropdownMenu.Root>
245
+ );
246
+ }
@@ -0,0 +1,70 @@
1
+ import React from "react";
2
+ import {
3
+ Breadcrumb,
4
+ BreadcrumbItem,
5
+ BreadcrumbLink,
6
+ BreadcrumbList,
7
+ BreadcrumbPage,
8
+ BreadcrumbSeparator,
9
+ } from "../ui/breadcrumb";
10
+
11
+ export interface BreadcrumbItem {
12
+ /** Display text for the breadcrumb */
13
+ text: string;
14
+ /** URL for the breadcrumb link */
15
+ href: string;
16
+ }
17
+
18
+ export interface AppBreadcrumbProps {
19
+ /** Array of breadcrumb items */
20
+ items: BreadcrumbItem[];
21
+ /** Callback when a breadcrumb link is clicked */
22
+ onFollow?: (event: React.MouseEvent, item: BreadcrumbItem) => void;
23
+ /** Additional class name */
24
+ className?: string;
25
+ }
26
+
27
+ /**
28
+ * AppBreadcrumb - A wrapper around reui Breadcrumb components
29
+ * that provides a similar API to Cloudscape's BreadcrumbGroup
30
+ */
31
+ export function AppBreadcrumb({ items, onFollow, className }: AppBreadcrumbProps) {
32
+ if (!items || items.length === 0) {
33
+ return null;
34
+ }
35
+
36
+ const handleClick = (event: React.MouseEvent, item: BreadcrumbItem) => {
37
+ if (onFollow) {
38
+ event.preventDefault();
39
+ onFollow(event, item);
40
+ }
41
+ };
42
+
43
+ return (
44
+ <Breadcrumb className={className}>
45
+ <BreadcrumbList>
46
+ {items.map((item, index) => {
47
+ const isLast = index === items.length - 1;
48
+
49
+ return (
50
+ <React.Fragment key={item.href || index}>
51
+ <BreadcrumbItem>
52
+ {isLast ? (
53
+ <BreadcrumbPage>{item.text}</BreadcrumbPage>
54
+ ) : (
55
+ <BreadcrumbLink
56
+ href={item.href}
57
+ onClick={(e) => handleClick(e, item)}
58
+ >
59
+ {item.text}
60
+ </BreadcrumbLink>
61
+ )}
62
+ </BreadcrumbItem>
63
+ {!isLast && <BreadcrumbSeparator />}
64
+ </React.Fragment>
65
+ );
66
+ })}
67
+ </BreadcrumbList>
68
+ </Breadcrumb>
69
+ );
70
+ }
@@ -0,0 +1,95 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import {
5
+ Alert,
6
+ AlertDescription,
7
+ AlertTitle,
8
+ AlertAction,
9
+ } from "../reui/alert";
10
+ import { Button } from "../ui/button";
11
+ import {
12
+ CircleCheckIcon,
13
+ CircleAlertIcon,
14
+ InfoIcon,
15
+ XCircleIcon,
16
+ XIcon,
17
+ } from "lucide-react";
18
+
19
+ export interface FlashbarMessage {
20
+ /** Unique identifier for the message */
21
+ id?: string;
22
+ /** Type of flash message - determines color and icon */
23
+ type?: "success" | "warning" | "info" | "error";
24
+ /** Header/title text for the message */
25
+ header?: React.ReactNode;
26
+ /** Main content/description of the message */
27
+ content?: React.ReactNode;
28
+ /** Whether the message can be dismissed */
29
+ dismissible?: boolean;
30
+ /** Callback when the dismiss button is clicked */
31
+ onDismiss?: () => void;
32
+ /** Action button or element to display */
33
+ action?: React.ReactNode;
34
+ /** Loading state for the message */
35
+ loading?: boolean;
36
+ }
37
+
38
+ export interface AppFlashbarProps {
39
+ /** Array of flash messages to display */
40
+ items: FlashbarMessage[];
41
+ }
42
+
43
+ const typeToVariant = {
44
+ success: "success",
45
+ warning: "warning",
46
+ info: "info",
47
+ error: "destructive",
48
+ } as const;
49
+
50
+ const typeToIcon = {
51
+ success: CircleCheckIcon,
52
+ warning: CircleAlertIcon,
53
+ info: InfoIcon,
54
+ error: XCircleIcon,
55
+ } as const;
56
+
57
+ export function AppFlashbar({ items }: AppFlashbarProps) {
58
+ if (!items || items.length === 0) {
59
+ return null;
60
+ }
61
+
62
+ return (
63
+ <div className="flex flex-col gap-2">
64
+ {items.map((item, index) => {
65
+ const variant = item.type ? typeToVariant[item.type] : "default";
66
+ const Icon = item.type ? typeToIcon[item.type] : InfoIcon;
67
+ const key = item.id ?? `flash-${index}`;
68
+
69
+ return (
70
+ <Alert key={key} variant={variant}>
71
+ <Icon aria-hidden="true" />
72
+ {item.header && <AlertTitle>{item.header}</AlertTitle>}
73
+ {item.content && <AlertDescription>{item.content}</AlertDescription>}
74
+ {(item.action || item.dismissible) && (
75
+ <AlertAction>
76
+ {item.action}
77
+ {item.dismissible && (
78
+ <Button
79
+ variant="ghost"
80
+ size="icon"
81
+ className="h-6 w-6"
82
+ onClick={item.onDismiss}
83
+ aria-label="Dismiss"
84
+ >
85
+ <XIcon className="h-4 w-4" />
86
+ </Button>
87
+ )}
88
+ </AlertAction>
89
+ )}
90
+ </Alert>
91
+ );
92
+ })}
93
+ </div>
94
+ );
95
+ }