@mostrom/app-shell 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/ralph-loop.local.md +9 -0
- package/README.md +172 -0
- package/bin/init.js +269 -0
- package/bun.lock +401 -0
- package/components.json +28 -0
- package/package.json +74 -0
- package/scripts/publish-npm.sh +202 -0
- package/src/AppShell.tsx +847 -0
- package/src/components/PageHeader.tsx +160 -0
- package/src/components/data-table/README.md +447 -0
- package/src/components/data-table/data-table-preferences.tsx +184 -0
- package/src/components/data-table/data-table-toolbar.tsx +118 -0
- package/src/components/data-table/data-table.tsx +37 -0
- package/src/components/data-table/index.ts +32 -0
- package/src/components/global-header/AllServicesButton.tsx +127 -0
- package/src/components/global-header/CategoriesButton.tsx +120 -0
- package/src/components/global-header/GlobalHeader.tsx +59 -0
- package/src/components/global-header/GlobalHeaderSearch.tsx +57 -0
- package/src/components/global-header/HeaderUtilities.tsx +243 -0
- package/src/components/global-header/ServicesMenu.tsx +246 -0
- package/src/components/layout/AppBreadcrumb.tsx +70 -0
- package/src/components/layout/AppFlashbar.tsx +95 -0
- package/src/components/layout/AppLayout.tsx +271 -0
- package/src/components/layout/AppNavigation.tsx +313 -0
- package/src/components/layout/AppSidebar.tsx +229 -0
- package/src/components/patterns/index.ts +14 -0
- package/src/components/patterns/p-alert-5.tsx +19 -0
- package/src/components/patterns/p-autocomplete-5.tsx +89 -0
- package/src/components/patterns/p-breadcrumb-1.tsx +28 -0
- package/src/components/patterns/p-button-42.tsx +37 -0
- package/src/components/patterns/p-button-51.tsx +14 -0
- package/src/components/patterns/p-button-6.tsx +5 -0
- package/src/components/patterns/p-calendar-1.tsx +18 -0
- package/src/components/patterns/p-card-1.tsx +33 -0
- package/src/components/patterns/p-card-2.tsx +26 -0
- package/src/components/patterns/p-card-5.tsx +31 -0
- package/src/components/patterns/p-collapsible-7.tsx +121 -0
- package/src/components/patterns/p-command-6.tsx +113 -0
- package/src/components/patterns/p-dialog-1.tsx +56 -0
- package/src/components/patterns/p-dropdown-menu-1.tsx +38 -0
- package/src/components/patterns/p-dropdown-menu-11.tsx +122 -0
- package/src/components/patterns/p-dropdown-menu-14.tsx +165 -0
- package/src/components/patterns/p-dropdown-menu-9.tsx +108 -0
- package/src/components/patterns/p-empty-2.tsx +34 -0
- package/src/components/patterns/p-file-upload-1.tsx +72 -0
- package/src/components/patterns/p-filters-1.tsx +666 -0
- package/src/components/patterns/p-frame-2.tsx +26 -0
- package/src/components/patterns/p-tabs-2.tsx +129 -0
- package/src/components/reui/alert.tsx +92 -0
- package/src/components/reui/autocomplete.tsx +343 -0
- package/src/components/reui/badge.tsx +87 -0
- package/src/components/reui/data-grid/data-grid-column-filter.tsx +165 -0
- package/src/components/reui/data-grid/data-grid-column-header.tsx +339 -0
- package/src/components/reui/data-grid/data-grid-column-visibility.tsx +55 -0
- package/src/components/reui/data-grid/data-grid-pagination.tsx +224 -0
- package/src/components/reui/data-grid/data-grid-table-dnd-rows.tsx +260 -0
- package/src/components/reui/data-grid/data-grid-table-dnd.tsx +253 -0
- package/src/components/reui/data-grid/data-grid-table.tsx +639 -0
- package/src/components/reui/data-grid/data-grid.tsx +209 -0
- package/src/components/reui/date-selector.tsx +1330 -0
- package/src/components/reui/filters.tsx +1869 -0
- package/src/components/reui/frame.tsx +134 -0
- package/src/components/reui/index.ts +17 -0
- package/src/components/reui/timeline.tsx +219 -0
- package/src/components/search/Autocomplete.tsx +183 -0
- package/src/components/search/AutocompleteClient.tsx +293 -0
- package/src/components/search/GlobalSearch.tsx +187 -0
- package/src/components/section-drawer/deal-drawer-content.tsx +891 -0
- package/src/components/section-drawer/index.ts +19 -0
- package/src/components/section-drawer/section-drawer.css +665 -0
- package/src/components/section-drawer/section-drawer.tsx +467 -0
- package/src/components/sectioned-list-board/README.md +78 -0
- package/src/components/sectioned-list-board/board-card-content.tsx +340 -0
- package/src/components/sectioned-list-board/date-range-filter.tsx +249 -0
- package/src/components/sectioned-list-board/index.ts +19 -0
- package/src/components/sectioned-list-board/sectioned-list-board.css +564 -0
- package/src/components/sectioned-list-board/sectioned-list-board.tsx +731 -0
- package/src/components/sectioned-list-board/sortable-card.tsx +314 -0
- package/src/components/sectioned-list-board/sortable-section.tsx +319 -0
- package/src/components/sectioned-list-board/types.ts +216 -0
- package/src/components/sectioned-list-table/README.md +80 -0
- package/src/components/sectioned-list-table/index.ts +14 -0
- package/src/components/sectioned-list-table/sectioned-list-table.css +534 -0
- package/src/components/sectioned-list-table/sectioned-list-table.tsx +740 -0
- package/src/components/sectioned-list-table/sortable-column-header.tsx +120 -0
- package/src/components/sectioned-list-table/sortable-row.tsx +420 -0
- package/src/components/sectioned-list-table/sortable-section.tsx +251 -0
- package/src/components/sectioned-list-table/table-cell-content.tsx +129 -0
- package/src/components/sectioned-list-table/types.ts +120 -0
- package/src/components/sectioned-list-table/use-column-preferences.ts +103 -0
- package/src/components/ui/actions-dropdown.tsx +109 -0
- package/src/components/ui/assignee-selector.tsx +209 -0
- package/src/components/ui/avatar.tsx +107 -0
- package/src/components/ui/breadcrumb.tsx +109 -0
- package/src/components/ui/button-group.tsx +83 -0
- package/src/components/ui/button.tsx +64 -0
- package/src/components/ui/calendar.tsx +220 -0
- package/src/components/ui/card.tsx +92 -0
- package/src/components/ui/chart.tsx +376 -0
- package/src/components/ui/checkbox.tsx +30 -0
- package/src/components/ui/collapsible.tsx +33 -0
- package/src/components/ui/command.tsx +182 -0
- package/src/components/ui/context-menu.tsx +250 -0
- package/src/components/ui/create-button-group.tsx +128 -0
- package/src/components/ui/dialog.tsx +156 -0
- package/src/components/ui/drawer.tsx +133 -0
- package/src/components/ui/dropdown-menu.tsx +255 -0
- package/src/components/ui/empty.tsx +104 -0
- package/src/components/ui/field.tsx +248 -0
- package/src/components/ui/form.tsx +165 -0
- package/src/components/ui/index.ts +37 -0
- package/src/components/ui/input-group.tsx +168 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/kbd.tsx +28 -0
- package/src/components/ui/label.tsx +22 -0
- package/src/components/ui/navigation-menu.tsx +168 -0
- package/src/components/ui/page-header.tsx +80 -0
- package/src/components/ui/popover.tsx +87 -0
- package/src/components/ui/scroll-area.tsx +56 -0
- package/src/components/ui/select.tsx +190 -0
- package/src/components/ui/separator.tsx +26 -0
- package/src/components/ui/sheet.tsx +141 -0
- package/src/components/ui/sidebar.tsx +726 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/sonner.tsx +38 -0
- package/src/components/ui/switch.tsx +33 -0
- package/src/components/ui/tabs.tsx +91 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/toggle-group.tsx +83 -0
- package/src/components/ui/toggle.tsx +45 -0
- package/src/components/ui/tooltip.tsx +57 -0
- package/src/hooks/use-copy-to-clipboard.ts +37 -0
- package/src/hooks/use-file-upload.ts +415 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/index.ts +95 -0
- package/src/lib/utils.ts +6 -0
- package/src/styles.css +1859 -0
- package/src/urls.ts +83 -0
- package/src/vite.d.ts +22 -0
- package/src/vite.js +241 -0
- package/tsconfig.base.json +18 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,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
|
+
}
|