@shellui/core 0.2.0-beta.0 → 0.2.0-beta.1

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 (60) hide show
  1. package/package.json +2 -2
  2. package/src/app.tsx +1 -1
  3. package/src/components/ContentView.tsx +26 -58
  4. package/src/components/LoadingOverlay.tsx +1 -1
  5. package/src/components/ui/sidebar.tsx +2 -124
  6. package/src/features/layouts/AppLayout.tsx +22 -19
  7. package/src/features/layouts/LayoutFallback.tsx +8 -0
  8. package/src/features/layouts/OverlayShell.tsx +21 -40
  9. package/src/features/layouts/{AppBarLayout.tsx → appbar/AppBarLayout.tsx} +72 -78
  10. package/src/features/layouts/{FullscreenLayout.tsx → fullscreen/FullscreenLayout.tsx} +5 -11
  11. package/src/features/layouts/sidebar/BottomNavItem.tsx +88 -0
  12. package/src/features/layouts/sidebar/MobileBottomNav.tsx +168 -0
  13. package/src/features/layouts/sidebar/NavigationContent.tsx +159 -0
  14. package/src/features/layouts/sidebar/SidebarIcons.tsx +93 -0
  15. package/src/features/layouts/sidebar/SidebarInner.tsx +48 -0
  16. package/src/features/layouts/sidebar/SidebarLayout.tsx +86 -0
  17. package/src/features/layouts/sidebar/sidebarUtils.ts +23 -0
  18. package/src/features/layouts/sidebar/types.ts +8 -0
  19. package/src/features/layouts/utils.ts +1 -1
  20. package/src/features/layouts/{WindowsLayout.tsx → windows/WindowsLayout.tsx} +199 -204
  21. package/src/features/settings/SettingsView.tsx +177 -180
  22. package/src/{components → routes/components}/HomeView.tsx +1 -1
  23. package/src/{components → routes/components}/IndexRoute.tsx +4 -4
  24. package/src/routes/components/NavigationItemRoute.tsx +19 -0
  25. package/src/{components → routes/components}/NotFoundView.tsx +3 -3
  26. package/src/{components → routes/components}/RouteErrorBoundary.tsx +1 -1
  27. package/src/routes/components/RouteFallback.tsx +8 -0
  28. package/src/routes/hooks/useNavigationItems.ts +84 -0
  29. package/src/{router → routes}/routes.tsx +10 -18
  30. package/src/components/ViewRoute.tsx +0 -74
  31. package/src/dist/CookiePreferencesView.52b5aec8.js +0 -1182
  32. package/src/dist/CookiePreferencesView.52b5aec8.js.map +0 -1
  33. package/src/dist/DefaultLayout.045a82ff.js +0 -1964
  34. package/src/dist/DefaultLayout.045a82ff.js.map +0 -1
  35. package/src/dist/DefaultLayout.4454f259.js +0 -4414
  36. package/src/dist/DefaultLayout.4454f259.js.map +0 -1
  37. package/src/dist/FullscreenLayout.555c4987.js +0 -1054
  38. package/src/dist/FullscreenLayout.555c4987.js.map +0 -1
  39. package/src/dist/HomeView.ddfa7b68.js +0 -771
  40. package/src/dist/HomeView.ddfa7b68.js.map +0 -1
  41. package/src/dist/NotFoundView.c75be4f1.js +0 -811
  42. package/src/dist/NotFoundView.c75be4f1.js.map +0 -1
  43. package/src/dist/SettingsView.052b03a6.js +0 -4965
  44. package/src/dist/SettingsView.052b03a6.js.map +0 -1
  45. package/src/dist/ViewRoute.e6e3b142.js +0 -1042
  46. package/src/dist/ViewRoute.e6e3b142.js.map +0 -1
  47. package/src/dist/WindowsLayout.08724167.js +0 -1762
  48. package/src/dist/WindowsLayout.08724167.js.map +0 -1
  49. package/src/dist/esm.f0d741e6.js +0 -29520
  50. package/src/dist/esm.f0d741e6.js.map +0 -1
  51. package/src/dist/favicon.4367ac1e.svg +0 -14
  52. package/src/dist/index.parcel.36d65383.js +0 -54089
  53. package/src/dist/index.parcel.36d65383.js.map +0 -1
  54. package/src/dist/index.parcel.ca6d8a47.css +0 -3493
  55. package/src/dist/index.parcel.ca6d8a47.css.map +0 -1
  56. package/src/dist/index.parcel.html +0 -88
  57. package/src/features/layouts/DefaultLayout.tsx +0 -670
  58. package/src/features/layouts/LayoutProviders.tsx +0 -20
  59. /package/src/{constants.ts → constants/loading.ts} +0 -0
  60. /package/src/{router → routes}/router.tsx +0 -0
@@ -2,7 +2,7 @@ import { useMemo, useEffect, type ReactNode } from 'react';
2
2
  import { Link, useLocation, Outlet, useNavigate } from 'react-router';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import { shellui } from '@shellui/sdk';
5
- import type { NavigationItem, NavigationGroup } from '../config/types';
5
+ import type { NavigationItem, NavigationGroup } from '../../config/types';
6
6
  import {
7
7
  filterNavigationByViewport,
8
8
  flattenNavigationItems,
@@ -11,12 +11,10 @@ import {
11
11
  resolveLocalizedString as resolveNavLabel,
12
12
  splitNavigationByPosition,
13
13
  withHomepageWhenNoRoot,
14
- } from './utils';
15
- import { LayoutProviders } from './LayoutProviders';
16
- import { OverlayShell } from './OverlayShell';
17
- import { Select } from '../../components/ui/select';
18
- import { AppBarTooltip, TooltipProvider } from '../../components/ui/tooltip';
19
- import { cn } from '../../lib/utils';
14
+ } from '../utils';
15
+ import { Select } from '../../../components/ui/select';
16
+ import { AppBarTooltip, TooltipProvider } from '../../../components/ui/tooltip';
17
+ import { cn } from '../../../lib/utils';
20
18
 
21
19
  const TOP_BAR_MAX_HEIGHT = 42;
22
20
 
@@ -187,81 +185,77 @@ export function AppBarLayout({ title, logo, navigation }: AppBarLayoutProps) {
187
185
  : `/${location.pathname.replace(/^\/+|\/+$/g, '').split('/')[0]}`;
188
186
 
189
187
  return (
190
- <LayoutProviders>
191
- <OverlayShell navigationItems={navigationItems}>
192
- <div className="flex flex-col h-screen overflow-hidden bg-background">
193
- {/* Top bar: max 42px */}
194
- <header
195
- className="flex items-center gap-3 px-3 border-b border-border bg-sidebar-background shrink-0"
196
- style={{ minHeight: 32, maxHeight: TOP_BAR_MAX_HEIGHT }}
197
- data-layout="app-bar"
198
- >
199
- {/* Logo / title (home link) */}
200
- <Link
201
- to="/"
202
- className="flex items-center gap-2 shrink-0 min-w-0 py-1.5 pr-2 text-sidebar-foreground hover:text-sidebar-foreground/80 transition-colors"
203
- >
204
- {logo && logo.trim() ? (
205
- <img
206
- src={logo}
207
- alt={title || 'Logo'}
208
- className="h-5 w-auto max-h-6 object-contain app-bar-logo m-1.5"
209
- />
210
- ) : title ? (
211
- <span className="text-sm font-semibold truncate">{title}</span>
212
- ) : null}
213
- </Link>
188
+ <div className="flex flex-col h-screen overflow-hidden bg-background">
189
+ {/* Top bar: max 42px */}
190
+ <header
191
+ className="flex items-center gap-3 px-3 border-b border-border bg-sidebar-background shrink-0"
192
+ style={{ minHeight: 32, maxHeight: TOP_BAR_MAX_HEIGHT }}
193
+ data-layout="app-bar"
194
+ >
195
+ {/* Logo / title (home link) */}
196
+ <Link
197
+ to="/"
198
+ className="flex items-center gap-2 shrink-0 min-w-0 py-1.5 pr-2 text-sidebar-foreground hover:text-sidebar-foreground/80 transition-colors"
199
+ >
200
+ {logo && logo.trim() ? (
201
+ <img
202
+ src={logo}
203
+ alt={title || 'Logo'}
204
+ className="h-5 w-auto max-h-6 object-contain app-bar-logo m-1.5"
205
+ />
206
+ ) : title ? (
207
+ <span className="text-sm font-semibold truncate">{title}</span>
208
+ ) : null}
209
+ </Link>
214
210
 
215
- {/* Start links: select menu (includes synthetic Homepage when nav has no "/" path) */}
216
- {displayStartItems.length > 0 && (
217
- <Select
218
- className="h-8 max-w-[200px] text-sm leading-tight py-1.5 border-sidebar-border bg-sidebar-background"
219
- value={currentPathPrefix}
220
- onChange={(e) => {
221
- const path = e.target.value;
222
- if (path) {
223
- navigate(path.startsWith('/') ? path : `/${path}`);
224
- }
225
- }}
211
+ {/* Start links: select menu (includes synthetic Homepage when nav has no "/" path) */}
212
+ {displayStartItems.length > 0 && (
213
+ <Select
214
+ className="h-8 max-w-[200px] text-sm leading-tight py-1.5 border-sidebar-border bg-sidebar-background"
215
+ value={currentPathPrefix}
216
+ onChange={(e) => {
217
+ const path = e.target.value;
218
+ if (path) {
219
+ navigate(path.startsWith('/') ? path : `/${path}`);
220
+ }
221
+ }}
222
+ >
223
+ {displayStartItems.map((item) => (
224
+ <option
225
+ key={item.path || 'root'}
226
+ value={getNavPathPrefix(item)}
226
227
  >
227
- {displayStartItems.map((item) => (
228
- <option
229
- key={item.path || 'root'}
230
- value={getNavPathPrefix(item)}
231
- >
232
- {resolveNavLabel(item.label, currentLanguage) || item.path || 'Home'}
233
- </option>
234
- ))}
235
- </Select>
236
- )}
228
+ {resolveNavLabel(item.label, currentLanguage) || item.path || 'Home'}
229
+ </option>
230
+ ))}
231
+ </Select>
232
+ )}
237
233
 
238
- <div className="flex-1 min-w-0" />
234
+ <div className="flex-1 min-w-0" />
239
235
 
240
- {/* End links: icon-only or first letter + tooltip */}
241
- {endNavItems.length > 0 && (
242
- <TooltipProvider
243
- delayDuration={200}
244
- skipDelayDuration={0}
245
- >
246
- <div className="flex items-center gap-0.5 shrink-0">
247
- {endNavItems.map((item) => (
248
- <TopBarEndItem
249
- key={item.path}
250
- item={item}
251
- label={resolveNavLabel(item.label, currentLanguage) || item.path || ''}
252
- activePathPrefix={activePathPrefix}
253
- />
254
- ))}
255
- </div>
256
- </TooltipProvider>
257
- )}
258
- </header>
236
+ {/* End links: icon-only or first letter + tooltip */}
237
+ {endNavItems.length > 0 && (
238
+ <TooltipProvider
239
+ delayDuration={200}
240
+ skipDelayDuration={0}
241
+ >
242
+ <div className="flex items-center gap-0.5 shrink-0">
243
+ {endNavItems.map((item) => (
244
+ <TopBarEndItem
245
+ key={item.path}
246
+ item={item}
247
+ label={resolveNavLabel(item.label, currentLanguage) || item.path || ''}
248
+ activePathPrefix={activePathPrefix}
249
+ />
250
+ ))}
251
+ </div>
252
+ </TooltipProvider>
253
+ )}
254
+ </header>
259
255
 
260
- <main className="flex-1 flex flex-col overflow-auto min-h-0">
261
- <Outlet />
262
- </main>
263
- </div>
264
- </OverlayShell>
265
- </LayoutProviders>
256
+ <main className="flex-1 flex flex-col overflow-auto min-h-0">
257
+ <Outlet />
258
+ </main>
259
+ </div>
266
260
  );
267
261
  }
@@ -1,10 +1,8 @@
1
1
  import { useMemo, useEffect } from 'react';
2
2
  import { Outlet, useLocation } from 'react-router';
3
3
  import { useTranslation } from 'react-i18next';
4
- import type { NavigationItem, NavigationGroup } from '../config/types';
5
- import { flattenNavigationItems } from './utils';
6
- import { LayoutProviders } from './LayoutProviders';
7
- import { OverlayShell } from './OverlayShell';
4
+ import type { NavigationItem, NavigationGroup } from '../../config/types';
5
+ import { flattenNavigationItems } from '../utils';
8
6
 
9
7
  interface FullscreenLayoutProps {
10
8
  title?: string;
@@ -44,12 +42,8 @@ export function FullscreenLayout({ title, navigation }: FullscreenLayoutProps) {
44
42
  }, [location.pathname, title, navigationItems, currentLanguage]);
45
43
 
46
44
  return (
47
- <LayoutProviders>
48
- <OverlayShell navigationItems={navigationItems}>
49
- <main className="flex flex-col w-full h-screen overflow-hidden bg-background">
50
- <Outlet />
51
- </main>
52
- </OverlayShell>
53
- </LayoutProviders>
45
+ <main className="flex flex-col w-full h-screen overflow-hidden bg-background">
46
+ <Outlet />
47
+ </main>
54
48
  );
55
49
  }
@@ -0,0 +1,88 @@
1
+ import { Link } from 'react-router';
2
+ import { shellui } from '@shellui/sdk';
3
+ import type { NavigationItem } from '../../config/types';
4
+ import { cn } from '../../../lib/utils';
5
+ import { getNavPathPrefix } from '../utils';
6
+
7
+ export function BottomNavItem({
8
+ item,
9
+ label,
10
+ isActive,
11
+ iconSrc,
12
+ applyIconTheme,
13
+ }: {
14
+ item: NavigationItem;
15
+ label: string;
16
+ isActive: boolean;
17
+ iconSrc: string | null;
18
+ applyIconTheme: boolean;
19
+ }) {
20
+ const pathPrefix = getNavPathPrefix(item);
21
+ const content = (
22
+ <span className="flex flex-col items-center justify-center gap-1 w-full min-w-0 max-w-full overflow-hidden">
23
+ {iconSrc ? (
24
+ <img
25
+ src={iconSrc}
26
+ alt=""
27
+ className={cn(
28
+ 'size-4 shrink-0 rounded-sm object-cover',
29
+ applyIconTheme && 'opacity-90 dark:opacity-100 dark:invert',
30
+ )}
31
+ />
32
+ ) : (
33
+ <span className="size-4 shrink-0 rounded-sm bg-muted" />
34
+ )}
35
+ <span className="text-[11px] leading-tight truncate w-full min-w-0 text-center block">
36
+ {label}
37
+ </span>
38
+ </span>
39
+ );
40
+ const baseClass = cn(
41
+ 'flex flex-col items-center justify-center rounded-md py-1.5 px-2 min-w-0 max-w-full transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
42
+ isActive
43
+ ? 'bg-accent text-accent-foreground [&_span]:text-accent-foreground'
44
+ : 'text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground [&_span]:inherit',
45
+ );
46
+ if (item.openIn === 'modal') {
47
+ return (
48
+ <button
49
+ type="button"
50
+ onClick={() => shellui.openModal(item.url)}
51
+ className={baseClass}
52
+ >
53
+ {content}
54
+ </button>
55
+ );
56
+ }
57
+ if (item.openIn === 'drawer') {
58
+ return (
59
+ <button
60
+ type="button"
61
+ onClick={() => shellui.openDrawer({ url: item.url, position: item.drawerPosition })}
62
+ className={baseClass}
63
+ >
64
+ {content}
65
+ </button>
66
+ );
67
+ }
68
+ if (item.openIn === 'external') {
69
+ return (
70
+ <a
71
+ href={item.url}
72
+ target="_blank"
73
+ rel="noopener noreferrer"
74
+ className={baseClass}
75
+ >
76
+ {content}
77
+ </a>
78
+ );
79
+ }
80
+ return (
81
+ <Link
82
+ to={pathPrefix}
83
+ className={baseClass}
84
+ >
85
+ {content}
86
+ </Link>
87
+ );
88
+ }
@@ -0,0 +1,168 @@
1
+ import { Link, useLocation } from 'react-router';
2
+ import { useMemo, useEffect, useState, useRef, useLayoutEffect } from 'react';
3
+ import type { NavigationItem } from '../../config/types';
4
+ import { cn } from '../../../lib/utils';
5
+ import { Z_INDEX } from '../../../lib/z-index';
6
+ import {
7
+ getActivePathPrefix,
8
+ getNavPathPrefix,
9
+ resolveLocalizedString as resolveNavLabel,
10
+ HOMEPAGE_NAV_ITEM,
11
+ } from '../utils';
12
+ import {
13
+ getExternalFaviconUrl,
14
+ isAppIcon,
15
+ BOTTOM_NAV_SLOT_WIDTH,
16
+ BOTTOM_NAV_GAP,
17
+ BOTTOM_NAV_PX,
18
+ BOTTOM_NAV_MAX_SLOTS,
19
+ } from './sidebarUtils';
20
+ import { BottomNavItem } from './BottomNavItem';
21
+ import { CaretUpIcon, CaretDownIcon, HomeIcon } from './SidebarIcons';
22
+
23
+ /** Mobile bottom nav: optional Home + nav items; More only when not all fit. Dynamic from width. */
24
+ export function MobileBottomNav({
25
+ items,
26
+ currentLanguage,
27
+ showHomeButton,
28
+ }: {
29
+ items: NavigationItem[];
30
+ currentLanguage: string;
31
+ showHomeButton: boolean;
32
+ }) {
33
+ const location = useLocation();
34
+ const [expanded, setExpanded] = useState(false);
35
+ const navRef = useRef<HTMLElement>(null);
36
+ const [rowWidth, setRowWidth] = useState(0);
37
+
38
+ const activePathPrefix = useMemo(
39
+ () => getActivePathPrefix(location.pathname, items),
40
+ [location.pathname, items],
41
+ );
42
+
43
+ useLayoutEffect(() => {
44
+ const el = navRef.current;
45
+ if (!el) return;
46
+ const ro = new ResizeObserver((entries) => {
47
+ const w = entries[0]?.contentRect.width ?? 0;
48
+ setRowWidth(w);
49
+ });
50
+ ro.observe(el);
51
+ setRowWidth(el.getBoundingClientRect().width);
52
+ return () => ro.disconnect();
53
+ }, []);
54
+
55
+ const { rowItems, overflowItems, hasMore } = useMemo(() => {
56
+ const list = items.slice();
57
+ const contentWidth = Math.max(0, rowWidth - BOTTOM_NAV_PX * 2);
58
+ const slotTotal = BOTTOM_NAV_SLOT_WIDTH + BOTTOM_NAV_GAP;
59
+ const computedSlots =
60
+ rowWidth > 0 ? Math.floor((contentWidth + BOTTOM_NAV_GAP) / slotTotal) : 5;
61
+ const totalSlots = Math.min(Math.max(0, computedSlots), BOTTOM_NAV_MAX_SLOTS);
62
+ const slotsForNav = showHomeButton ? totalSlots - 1 : totalSlots;
63
+ const allFit = list.length <= slotsForNav;
64
+ const maxInRow = allFit
65
+ ? list.length
66
+ : Math.max(0, showHomeButton ? totalSlots - 2 : totalSlots - 1);
67
+ const row = list.slice(0, maxInRow);
68
+ const rowPaths = new Set(row.map((i) => i.path));
69
+ const overflow = list.filter((item) => !rowPaths.has(item.path));
70
+ return {
71
+ rowItems: row,
72
+ overflowItems: overflow,
73
+ hasMore: overflow.length > 0,
74
+ };
75
+ }, [items, rowWidth, showHomeButton]);
76
+
77
+ useEffect(() => {
78
+ setExpanded(false);
79
+ }, [location.pathname]);
80
+
81
+ const renderItem = (item: NavigationItem, index: number) => {
82
+ const pathPrefix = getNavPathPrefix(item);
83
+ const isOverlayOrExternal =
84
+ item.openIn === 'modal' || item.openIn === 'drawer' || item.openIn === 'external';
85
+ const isActive = !isOverlayOrExternal && pathPrefix === activePathPrefix;
86
+ const label = resolveNavLabel(item.label, currentLanguage);
87
+ const faviconUrl =
88
+ item.openIn === 'external' && !item.icon ? getExternalFaviconUrl(item.url) : null;
89
+ const iconSrc = item.icon ?? faviconUrl ?? null;
90
+ const applyIconTheme = iconSrc ? isAppIcon(iconSrc) : false;
91
+ return (
92
+ <BottomNavItem
93
+ key={`${item.path}-${item.url}-${index}`}
94
+ item={item}
95
+ label={label}
96
+ isActive={isActive}
97
+ iconSrc={iconSrc}
98
+ applyIconTheme={applyIconTheme}
99
+ />
100
+ );
101
+ };
102
+
103
+ return (
104
+ <nav
105
+ ref={navRef}
106
+ className="fixed bottom-0 left-0 right-0 z-[9999] md:hidden border-t border-sidebar-border bg-sidebar-background overflow-hidden pt-2"
107
+ style={{
108
+ zIndex: Z_INDEX.SIDEBAR_TRIGGER,
109
+ paddingBottom: 'calc(0.5rem + env(safe-area-inset-bottom, 0px))',
110
+ }}
111
+ >
112
+ <div className="flex flex-row flex-nowrap items-center justify-center gap-1 px-3 overflow-x-hidden">
113
+ {showHomeButton && (
114
+ <Link
115
+ to="/"
116
+ className={cn(
117
+ 'flex flex-col items-center justify-center gap-1 rounded-md py-1.5 px-2 min-w-0 transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
118
+ location.pathname === '/' || location.pathname === ''
119
+ ? 'bg-sidebar-accent text-sidebar-accent-foreground [&_span]:text-sidebar-accent-foreground'
120
+ : 'text-sidebar-foreground/80 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground [&_span]:inherit',
121
+ )}
122
+ aria-label={resolveNavLabel(HOMEPAGE_NAV_ITEM.label, currentLanguage) || 'Home'}
123
+ >
124
+ <span className="size-4 shrink-0 flex items-center justify-center [&_svg]:text-current">
125
+ <HomeIcon className="size-4" />
126
+ </span>
127
+ <span className="text-[11px] leading-tight">
128
+ {resolveNavLabel(HOMEPAGE_NAV_ITEM.label, currentLanguage) || 'Home'}
129
+ </span>
130
+ </Link>
131
+ )}
132
+ {rowItems.map((item, i) => renderItem(item, i))}
133
+ {hasMore && (
134
+ <button
135
+ type="button"
136
+ onClick={() => setExpanded((e) => !e)}
137
+ className={cn(
138
+ 'flex flex-col items-center justify-center gap-1 rounded-md py-1.5 px-2 min-w-0 transition-colors cursor-pointer',
139
+ 'text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
140
+ )}
141
+ aria-expanded={expanded}
142
+ aria-label={expanded ? 'Show less' : 'Show more'}
143
+ >
144
+ <span className="size-4 shrink-0 flex items-center justify-center">
145
+ {expanded ? <CaretDownIcon className="size-4" /> : <CaretUpIcon className="size-4" />}
146
+ </span>
147
+ <span className="text-[11px] leading-tight">{expanded ? 'Less' : 'More'}</span>
148
+ </button>
149
+ )}
150
+ </div>
151
+
152
+ <div
153
+ className={cn(
154
+ 'grid transition-[grid-template-rows] duration-300 ease-out',
155
+ expanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
156
+ )}
157
+ >
158
+ <div className="min-h-0 overflow-hidden">
159
+ <div className="px-4 pt-3 pb-2 border-t border-sidebar-border/50 mt-1">
160
+ <div className="grid grid-cols-5 gap-2 justify-items-center max-w-xs mx-auto">
161
+ {expanded ? overflowItems.map((item, i) => renderItem(item, i)) : null}
162
+ </div>
163
+ </div>
164
+ </div>
165
+ </div>
166
+ </nav>
167
+ );
168
+ }
@@ -0,0 +1,159 @@
1
+ import { Link, useLocation } from 'react-router';
2
+ import { useMemo } from 'react';
3
+ import { useTranslation } from 'react-i18next';
4
+ import { shellui } from '@shellui/sdk';
5
+ import type { NavigationItem, NavigationGroup } from '../../config/types';
6
+ import {
7
+ SidebarGroup,
8
+ SidebarGroupLabel,
9
+ SidebarGroupContent,
10
+ SidebarMenu,
11
+ SidebarMenuItem,
12
+ SidebarMenuButton,
13
+ } from '../../../components/ui/sidebar';
14
+ import { cn } from '../../../lib/utils';
15
+ import { getActivePathPrefix, getNavPathPrefix, flattenNavigationItems } from '../utils';
16
+ import { getExternalFaviconUrl } from './sidebarUtils';
17
+ import { ExternalLinkIcon } from './SidebarIcons';
18
+
19
+ export function NavigationContent({
20
+ navigation,
21
+ }: {
22
+ navigation: (NavigationItem | NavigationGroup)[];
23
+ }) {
24
+ const location = useLocation();
25
+ const { i18n } = useTranslation();
26
+ const currentLanguage = i18n.language || 'en';
27
+
28
+ const resolveLocalizedString = (
29
+ value: string | { en: string; fr: string; [key: string]: string },
30
+ lang: string,
31
+ ): string => {
32
+ if (typeof value === 'string') return value;
33
+ return value[lang] || value.en || value.fr || Object.values(value)[0] || '';
34
+ };
35
+
36
+ const hasAnyIcons = useMemo(() => {
37
+ return navigation.some((item) => {
38
+ if ('title' in item && 'items' in item) {
39
+ return (item as NavigationGroup).items.some((navItem) => !!navItem.icon);
40
+ }
41
+ return !!(item as NavigationItem).icon;
42
+ });
43
+ }, [navigation]);
44
+
45
+ const flatItems = useMemo(() => flattenNavigationItems(navigation), [navigation]);
46
+ const activePathPrefix = useMemo(
47
+ () => getActivePathPrefix(location.pathname, flatItems),
48
+ [location.pathname, flatItems],
49
+ );
50
+
51
+ const isGroup = (item: NavigationItem | NavigationGroup): item is NavigationGroup => {
52
+ return 'title' in item && 'items' in item;
53
+ };
54
+
55
+ const renderNavItem = (navItem: NavigationItem) => {
56
+ const pathPrefix = getNavPathPrefix(navItem);
57
+ const isOverlay = navItem.openIn === 'modal' || navItem.openIn === 'drawer';
58
+ const isExternal = navItem.openIn === 'external';
59
+ const isActive = !isOverlay && !isExternal && pathPrefix === activePathPrefix;
60
+ const itemLabel = resolveLocalizedString(navItem.label, currentLanguage);
61
+ const faviconUrl = isExternal && !navItem.icon ? getExternalFaviconUrl(navItem.url) : null;
62
+ const iconSrc = navItem.icon ?? faviconUrl ?? null;
63
+ const iconEl = iconSrc ? (
64
+ <img
65
+ src={iconSrc}
66
+ alt=""
67
+ className={cn('h-4 w-4', 'shrink-0')}
68
+ />
69
+ ) : hasAnyIcons ? (
70
+ <span className="h-4 w-4 shrink-0" />
71
+ ) : null;
72
+ const externalIcon = isExternal ? (
73
+ <ExternalLinkIcon className="ml-auto h-4 w-4 shrink-0 opacity-70" />
74
+ ) : null;
75
+ const content = (
76
+ <>
77
+ {iconEl}
78
+ <span className="truncate">{itemLabel}</span>
79
+ {externalIcon}
80
+ </>
81
+ );
82
+ const linkOrTrigger =
83
+ navItem.openIn === 'modal' ? (
84
+ <button
85
+ type="button"
86
+ onClick={() => shellui.openModal(navItem.url)}
87
+ className="flex items-center gap-2 w-full cursor-pointer text-left"
88
+ >
89
+ {content}
90
+ </button>
91
+ ) : navItem.openIn === 'drawer' ? (
92
+ <button
93
+ type="button"
94
+ onClick={() => shellui.openDrawer({ url: navItem.url, position: navItem.drawerPosition })}
95
+ className="flex items-center gap-2 w-full cursor-pointer text-left"
96
+ >
97
+ {content}
98
+ </button>
99
+ ) : navItem.openIn === 'external' ? (
100
+ <a
101
+ href={navItem.url}
102
+ target="_blank"
103
+ rel="noopener noreferrer"
104
+ className="flex items-center gap-2 w-full"
105
+ >
106
+ {content}
107
+ </a>
108
+ ) : (
109
+ <Link
110
+ to={pathPrefix}
111
+ className="flex items-center gap-2 w-full"
112
+ >
113
+ {content}
114
+ </Link>
115
+ );
116
+ return (
117
+ <SidebarMenuButton
118
+ asChild
119
+ isActive={isActive}
120
+ className={cn('w-full', isActive && 'bg-sidebar-accent text-sidebar-accent-foreground')}
121
+ >
122
+ {linkOrTrigger}
123
+ </SidebarMenuButton>
124
+ );
125
+ };
126
+
127
+ return (
128
+ <>
129
+ {navigation.map((item) => {
130
+ if (isGroup(item)) {
131
+ const groupTitle = resolveLocalizedString(item.title, currentLanguage);
132
+ return (
133
+ <SidebarGroup
134
+ key={groupTitle}
135
+ className="mt-0"
136
+ >
137
+ <SidebarGroupLabel className="mb-1">{groupTitle}</SidebarGroupLabel>
138
+ <SidebarGroupContent>
139
+ <SidebarMenu className="gap-0.5">
140
+ {item.items.map((navItem) => (
141
+ <SidebarMenuItem key={navItem.path}>{renderNavItem(navItem)}</SidebarMenuItem>
142
+ ))}
143
+ </SidebarMenu>
144
+ </SidebarGroupContent>
145
+ </SidebarGroup>
146
+ );
147
+ }
148
+ return (
149
+ <SidebarMenu
150
+ key={item.path}
151
+ className="gap-0.5"
152
+ >
153
+ <SidebarMenuItem>{renderNavItem(item)}</SidebarMenuItem>
154
+ </SidebarMenu>
155
+ );
156
+ })}
157
+ </>
158
+ );
159
+ }