@mostrom/app-shell 0.1.4 → 0.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mostrom/app-shell",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -29,7 +29,7 @@
29
29
  "@dnd-kit/utilities": "^3.2.2",
30
30
  "@floating-ui/dom": "^1.7.5",
31
31
  "@hookform/resolvers": "^5.2.2",
32
- "@platform/service-catalog": "npm:@mostrom/service-catalog@^0.1.4",
32
+ "@platform/service-catalog": "npm:@mostrom/service-catalog@^0.1.5",
33
33
  "@radix-ui/react-dropdown-menu": "^2.1.4",
34
34
  "@tanstack/react-table": "^8.21.3",
35
35
  "class-variance-authority": "^0.7.1",
package/src/AppShell.tsx CHANGED
@@ -157,12 +157,6 @@ export interface AppShellProps {
157
157
  */
158
158
  appsMenuItems?: MenuDropdownItems;
159
159
 
160
- /**
161
- * Menu items for the Categories button.
162
- * Displays the available service categories.
163
- */
164
- categoriesMenuItems?: MenuDropdownItems;
165
-
166
160
  /**
167
161
  * Menu items for the Settings dropdown.
168
162
  * Contains user preferences, notifications settings, and integrations.
@@ -178,9 +172,6 @@ export interface AppShellProps {
178
172
  /** Callback when an item in the All Services menu is clicked */
179
173
  onAppsMenuItemClick?: MenuItemClickHandler;
180
174
 
181
- /** Callback when an item in the Categories menu is clicked */
182
- onCategoriesMenuItemClick?: MenuItemClickHandler;
183
-
184
175
  /** Callback when an item in the Settings menu is clicked */
185
176
  onSettingsMenuItemClick?: MenuItemClickHandler;
186
177
 
@@ -315,11 +306,9 @@ export function AppShell({
315
306
  searchValue,
316
307
  searchPlaceholder,
317
308
  appsMenuItems,
318
- categoriesMenuItems,
319
309
  settingsMenuItems,
320
310
  userMenuItems,
321
311
  onAppsMenuItemClick,
322
- onCategoriesMenuItemClick,
323
312
  onSettingsMenuItemClick,
324
313
  onUserMenuItemClick,
325
314
  onThemeToggle,
@@ -562,31 +551,6 @@ export function AppShell({
562
551
  }));
563
552
  }, [serviceCatalog, serviceGroups]);
564
553
 
565
- const catalogCategoryMenuItems = React.useMemo<MenuDropdownItems>(() => {
566
- if (!serviceCatalog) {
567
- return [{ id: "loading", text: "Loading categories", disabled: true }];
568
- }
569
-
570
- const categoryHrefById = new Map<string, string>();
571
-
572
- for (const group of serviceGroups) {
573
- const serviceHref = group.services[0]?.href;
574
- if (serviceHref && serviceHref.startsWith("/")) {
575
- const rootSegment = serviceHref.split("/").filter(Boolean)[0];
576
- if (rootSegment) {
577
- categoryHrefById.set(group.category.id, `/${rootSegment}/`);
578
- }
579
- }
580
- }
581
-
582
- return serviceCatalog.categories.map((category) => ({
583
- id: category.id,
584
- text: category.label,
585
- description: category.description,
586
- href: categoryHrefById.get(category.id),
587
- }));
588
- }, [serviceCatalog, serviceGroups]);
589
-
590
554
  const hasSearchQuery = searchInputValue.trim().length > 0;
591
555
 
592
556
  const searchResults = React.useMemo(() => {
@@ -667,8 +631,6 @@ export function AppShell({
667
631
  ) : undefined;
668
632
 
669
633
  const resolvedAppsMenuItems = appsMenuItems ?? catalogMenuItems ?? EMPTY_MENU_ITEMS;
670
- const resolvedCategoriesMenuItems =
671
- categoriesMenuItems ?? catalogCategoryMenuItems ?? EMPTY_MENU_ITEMS;
672
634
  const resolvedIdentity =
673
635
  identity ?? {
674
636
  logo: {
@@ -724,8 +686,6 @@ export function AppShell({
724
686
  <GlobalHeaderSearch
725
687
  appsMenuItems={resolvedAppsMenuItems}
726
688
  onAppsMenuItemClick={onAppsMenuItemClick}
727
- categoriesMenuItems={resolvedCategoriesMenuItems}
728
- onCategoriesMenuItemClick={onCategoriesMenuItemClick}
729
689
  searchValue={searchInputValue}
730
690
  onSearchChange={handleSearchValueChange}
731
691
  onSearchSelect={handleSearchSelect}
@@ -10,8 +10,6 @@ import {
10
10
  export type GlobalHeaderSearchProps = {
11
11
  appsMenuItems: MenuDropdownItems;
12
12
  onAppsMenuItemClick?: MenuItemClickHandler;
13
- categoriesMenuItems: MenuDropdownItems;
14
- onCategoriesMenuItemClick?: MenuItemClickHandler;
15
13
  searchValue: string;
16
14
  onSearchChange: (value: string) => void;
17
15
  onSearchSelect: (option: SearchOption) => void;
@@ -24,8 +22,6 @@ export type GlobalHeaderSearchProps = {
24
22
  export function GlobalHeaderSearch({
25
23
  appsMenuItems,
26
24
  onAppsMenuItemClick,
27
- categoriesMenuItems,
28
- onCategoriesMenuItemClick,
29
25
  searchValue,
30
26
  onSearchChange,
31
27
  onSearchSelect,
@@ -39,9 +35,7 @@ export function GlobalHeaderSearch({
39
35
  <span className="app-shell-separator">|</span>
40
36
  <ServicesMenu
41
37
  appsMenuItems={appsMenuItems}
42
- categoriesMenuItems={categoriesMenuItems}
43
38
  onAppsMenuItemClick={onAppsMenuItemClick}
44
- onCategoriesMenuItemClick={onCategoriesMenuItemClick}
45
39
  />
46
40
  <GlobalSearch
47
41
  value={searchValue}
@@ -1,6 +1,13 @@
1
- import { useState, useMemo, type MouseEvent } from "react";
1
+ import { useMemo, type MouseEvent } from "react";
2
2
 
3
- import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
3
+ import {
4
+ NavigationMenu,
5
+ NavigationMenuContent,
6
+ NavigationMenuItem,
7
+ NavigationMenuLink,
8
+ NavigationMenuList,
9
+ NavigationMenuTrigger,
10
+ } from "@/components/ui/navigation-menu";
4
11
 
5
12
  // Menu dropdown types
6
13
  export interface MenuDropdownItem {
@@ -31,9 +38,7 @@ export type MenuItemClickHandler = (detail: MenuItemClickDetail) => void;
31
38
 
32
39
  export type ServicesMenuProps = {
33
40
  appsMenuItems: MenuDropdownItems;
34
- categoriesMenuItems: MenuDropdownItems;
35
41
  onAppsMenuItemClick?: MenuItemClickHandler;
36
- onCategoriesMenuItemClick?: MenuItemClickHandler;
37
42
  };
38
43
 
39
44
  type MenuItem = MenuDropdownItemOrGroup;
@@ -71,32 +76,11 @@ const flattenItems = (items: MenuDropdownItems): MenuDropdownItem[] => {
71
76
  return result;
72
77
  };
73
78
 
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
79
  export function ServicesMenu({
86
80
  appsMenuItems,
87
- categoriesMenuItems,
88
81
  onAppsMenuItemClick,
89
- onCategoriesMenuItemClick,
90
82
  }: 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
- );
83
+ const items = useMemo(() => flattenItems(appsMenuItems), [appsMenuItems]);
100
84
 
101
85
  const handleItemClick =
102
86
  (item: MenuDropdownItem) => (event: MouseEvent<HTMLElement>) => {
@@ -111,136 +95,48 @@ export function ServicesMenu({
111
95
  href: item.href,
112
96
  external: item.external,
113
97
  });
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
98
  };
137
99
 
138
100
  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>
101
+ <NavigationMenu>
102
+ <NavigationMenuList>
103
+ <NavigationMenuItem>
104
+ <NavigationMenuTrigger className="app-shell-apps-button h-9 w-9 p-0 bg-transparent hover:bg-transparent data-[state=open]:bg-transparent">
105
+ <span className="app-shell-apps-icon">
106
+ <DotsNineIcon />
107
+ </span>
108
+ </NavigationMenuTrigger>
109
+ <NavigationMenuContent>
110
+ <ul className="grid w-[400px] gap-2 p-2 md:w-[500px] md:grid-cols-2">
111
+ {items.map((item) => (
112
+ <li key={item.id}>
113
+ <NavigationMenuLink
114
+ asChild
115
+ className="flex flex-col items-start gap-1 p-3 rounded-md hover:bg-accent"
116
+ >
117
+ <a
118
+ href={item.href || "#"}
119
+ target={item.external ? "_blank" : undefined}
120
+ rel={item.external ? "noopener noreferrer" : undefined}
121
+ aria-disabled={item.disabled || undefined}
122
+ onClick={handleItemClick(item)}
123
+ >
124
+ <div className="text-sm leading-none font-medium">
125
+ {item.text}
126
+ </div>
127
+ {item.description && (
128
+ <p className="text-muted-foreground line-clamp-2 text-sm leading-snug">
129
+ {item.description}
130
+ </p>
131
+ )}
132
+ </a>
133
+ </NavigationMenuLink>
134
+ </li>
239
135
  ))}
240
- </div>
241
- </div>
242
- </DropdownMenu.Content>
243
- </DropdownMenu.Portal>
244
- </DropdownMenu.Root>
136
+ </ul>
137
+ </NavigationMenuContent>
138
+ </NavigationMenuItem>
139
+ </NavigationMenuList>
140
+ </NavigationMenu>
245
141
  );
246
142
  }
@@ -1,127 +0,0 @@
1
- "use client";
2
-
3
- import React from "react";
4
- import { Button } from "../ui/button";
5
- import {
6
- DropdownMenu,
7
- DropdownMenuContent,
8
- DropdownMenuGroup,
9
- DropdownMenuItem,
10
- DropdownMenuLabel,
11
- DropdownMenuSeparator,
12
- DropdownMenuTrigger,
13
- } from "../ui/dropdown-menu";
14
- import type {
15
- MenuDropdownItems,
16
- MenuDropdownItem,
17
- MenuDropdownItemGroup,
18
- MenuItemClickHandler,
19
- } from "./ServicesMenu";
20
-
21
- type AllServicesButtonProps = {
22
- items: MenuDropdownItems;
23
- onItemClick?: MenuItemClickHandler;
24
- };
25
-
26
- const DotsNineIcon = () => (
27
- <svg
28
- aria-hidden="true"
29
- focusable="false"
30
- width="24"
31
- height="24"
32
- viewBox="0 0 24 24"
33
- >
34
- {([5, 12, 19] as const).flatMap((cx) =>
35
- ([5, 12, 19] as const).map((cy) => (
36
- <circle key={`${cx}-${cy}`} cx={cx} cy={cy} r={1.6} fill="currentColor" />
37
- )),
38
- )}
39
- </svg>
40
- );
41
-
42
- const isGroup = (item: MenuDropdownItem | MenuDropdownItemGroup): item is MenuDropdownItemGroup =>
43
- typeof (item as MenuDropdownItemGroup).items !== "undefined";
44
-
45
- export function AllServicesButton({ items, onItemClick }: AllServicesButtonProps) {
46
- const handleItemClick = (item: MenuDropdownItem) => {
47
- if (item.disabled) return;
48
- onItemClick?.({
49
- id: item.id,
50
- href: item.href,
51
- external: item.external,
52
- });
53
- };
54
-
55
- const renderItem = (item: MenuDropdownItem) => {
56
- const content = (
57
- <>
58
- <span>{item.text}</span>
59
- {item.description && (
60
- <span className="text-xs text-muted-foreground ml-2">{item.description}</span>
61
- )}
62
- </>
63
- );
64
-
65
- if (item.href) {
66
- return (
67
- <DropdownMenuItem key={item.id} asChild disabled={item.disabled}>
68
- <a
69
- href={item.href}
70
- target={item.external ? "_blank" : undefined}
71
- rel={item.external ? "noopener noreferrer" : undefined}
72
- onClick={() => handleItemClick(item)}
73
- >
74
- {content}
75
- </a>
76
- </DropdownMenuItem>
77
- );
78
- }
79
-
80
- return (
81
- <DropdownMenuItem
82
- key={item.id}
83
- disabled={item.disabled}
84
- onSelect={() => handleItemClick(item)}
85
- >
86
- {content}
87
- </DropdownMenuItem>
88
- );
89
- };
90
-
91
- return (
92
- <div className="app-shell-apps-button">
93
- <DropdownMenu>
94
- <DropdownMenuTrigger asChild>
95
- <Button
96
- variant="ghost"
97
- size="icon"
98
- aria-label="Apps"
99
- className="h-9 w-9 hover:bg-transparent"
100
- >
101
- <span className="app-shell-apps-icon">
102
- <DotsNineIcon />
103
- </span>
104
- </Button>
105
- </DropdownMenuTrigger>
106
- <DropdownMenuContent align="start" sideOffset={8} className="w-64 max-h-96 overflow-y-auto">
107
- {items.map((item, index) => {
108
- if (isGroup(item)) {
109
- return (
110
- <React.Fragment key={item.text || `group-${index}`}>
111
- {index > 0 && <DropdownMenuSeparator />}
112
- <DropdownMenuGroup>
113
- {item.text && (
114
- <DropdownMenuLabel>{item.text}</DropdownMenuLabel>
115
- )}
116
- {item.items.map((subItem) => renderItem(subItem as MenuDropdownItem))}
117
- </DropdownMenuGroup>
118
- </React.Fragment>
119
- );
120
- }
121
- return renderItem(item as MenuDropdownItem);
122
- })}
123
- </DropdownMenuContent>
124
- </DropdownMenu>
125
- </div>
126
- );
127
- }
@@ -1,119 +0,0 @@
1
- "use client";
2
-
3
- import { Button } from "../ui/button";
4
- import {
5
- DropdownMenu,
6
- DropdownMenuContent,
7
- DropdownMenuItem,
8
- DropdownMenuTrigger,
9
- } from "../ui/dropdown-menu";
10
- import type {
11
- MenuDropdownItems,
12
- MenuDropdownItem,
13
- MenuDropdownItemGroup,
14
- MenuItemClickHandler,
15
- } from "./ServicesMenu";
16
-
17
- type CategoriesButtonProps = {
18
- items: MenuDropdownItems;
19
- onItemClick?: MenuItemClickHandler;
20
- };
21
-
22
- const SquaresFourIcon = () => (
23
- <svg
24
- aria-hidden="true"
25
- focusable="false"
26
- width="22"
27
- height="22"
28
- viewBox="0 0 24 24"
29
- >
30
- <rect x="4" y="4" width="7" height="7" rx="1.4" fill="currentColor" />
31
- <rect x="13" y="4" width="7" height="7" rx="1.4" fill="currentColor" />
32
- <rect x="4" y="13" width="7" height="7" rx="1.4" fill="currentColor" />
33
- <rect x="13" y="13" width="7" height="7" rx="1.4" fill="currentColor" />
34
- </svg>
35
- );
36
-
37
- const isGroup = (item: MenuDropdownItem | MenuDropdownItemGroup): item is MenuDropdownItemGroup =>
38
- typeof (item as MenuDropdownItemGroup).items !== "undefined";
39
-
40
- const flattenItems = (items: MenuDropdownItems): MenuDropdownItem[] => {
41
- const result: MenuDropdownItem[] = [];
42
- items.forEach((item) => {
43
- if (isGroup(item)) {
44
- result.push(...flattenItems(item.items));
45
- } else {
46
- result.push(item as MenuDropdownItem);
47
- }
48
- });
49
- return result;
50
- };
51
-
52
- export function CategoriesButton({ items, onItemClick }: CategoriesButtonProps) {
53
- const flatItems = flattenItems(items);
54
-
55
- const handleItemClick = (item: MenuDropdownItem) => {
56
- if (item.disabled) return;
57
- onItemClick?.({
58
- id: item.id,
59
- href: item.href,
60
- external: item.external,
61
- });
62
- };
63
-
64
- return (
65
- <div className="app-shell-categories-button">
66
- <DropdownMenu>
67
- <DropdownMenuTrigger asChild>
68
- <Button
69
- variant="ghost"
70
- size="icon"
71
- aria-label="Categories"
72
- className="h-9 w-9 hover:bg-transparent"
73
- >
74
- <span className="app-shell-categories-icon">
75
- <SquaresFourIcon />
76
- </span>
77
- </Button>
78
- </DropdownMenuTrigger>
79
- <DropdownMenuContent align="start" sideOffset={8} className="w-56">
80
- {flatItems.map((item) => {
81
- const content = (
82
- <>
83
- <span>{item.text}</span>
84
- {item.description && (
85
- <span className="text-xs text-muted-foreground ml-2">{item.description}</span>
86
- )}
87
- </>
88
- );
89
-
90
- if (item.href) {
91
- return (
92
- <DropdownMenuItem key={item.id} asChild disabled={item.disabled}>
93
- <a
94
- href={item.href}
95
- target={item.external ? "_blank" : undefined}
96
- rel={item.external ? "noopener noreferrer" : undefined}
97
- onClick={() => handleItemClick(item)}
98
- >
99
- {content}
100
- </a>
101
- </DropdownMenuItem>
102
- );
103
- }
104
-
105
- return (
106
- <DropdownMenuItem
107
- key={item.id}
108
- disabled={item.disabled}
109
- onSelect={() => handleItemClick(item)}
110
- >
111
- {content}
112
- </DropdownMenuItem>
113
- );
114
- })}
115
- </DropdownMenuContent>
116
- </DropdownMenu>
117
- </div>
118
- );
119
- }