@shellui/core 0.1.0 → 0.2.0-alpha.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 (34) hide show
  1. package/package.json +4 -2
  2. package/src/components/AppPathView.tsx +31 -0
  3. package/src/components/ContentView.tsx +14 -6
  4. package/src/components/HomeView.tsx +9 -2
  5. package/src/components/IndexRoute.tsx +37 -0
  6. package/src/components/NotFoundView.tsx +3 -2
  7. package/src/components/ViewRoute.tsx +11 -7
  8. package/src/components/ui/tooltip.tsx +52 -0
  9. package/src/constants/urls.ts +2 -0
  10. package/src/features/config/ConfigProvider.ts +20 -76
  11. package/src/features/config/shellui-config.d.ts +13 -0
  12. package/src/features/config/types.ts +14 -3
  13. package/src/features/config/useConfig.ts +1 -10
  14. package/src/features/cookieConsent/cookieConsent.ts +2 -4
  15. package/src/features/layouts/AppBarLayout.tsx +260 -0
  16. package/src/features/layouts/AppLayout.tsx +6 -0
  17. package/src/features/layouts/DefaultLayout.tsx +25 -17
  18. package/src/features/layouts/OverlayShell.tsx +19 -8
  19. package/src/features/layouts/WindowsLayout.tsx +11 -9
  20. package/src/features/layouts/utils.ts +44 -0
  21. package/src/features/sentry/initSentry.ts +82 -12
  22. package/src/features/settings/SettingsProvider.tsx +2 -1
  23. package/src/features/settings/SettingsView.tsx +79 -15
  24. package/src/features/settings/components/Advanced.tsx +17 -2
  25. package/src/features/settings/components/ApplicationSettingsPanel.tsx +25 -0
  26. package/src/features/settings/components/Develop.tsx +68 -4
  27. package/src/i18n/translations/en/common.json +5 -0
  28. package/src/i18n/translations/en/settings.json +3 -1
  29. package/src/i18n/translations/fr/common.json +5 -0
  30. package/src/i18n/translations/fr/settings.json +3 -1
  31. package/src/index.css +10 -0
  32. package/src/lib/z-index.ts +2 -0
  33. package/src/router/routes.tsx +18 -5
  34. package/tailwind.config.js +1 -1
@@ -0,0 +1,260 @@
1
+ import { useMemo, useEffect, type ReactNode } from 'react';
2
+ import { Link, useLocation, Outlet, useNavigate } from 'react-router';
3
+ import { useTranslation } from 'react-i18next';
4
+ import { shellui } from '@shellui/sdk';
5
+ import type { NavigationItem, NavigationGroup } from '../config/types';
6
+ import {
7
+ filterNavigationByViewport,
8
+ flattenNavigationItems,
9
+ getEffectiveUrl,
10
+ getNavPathPrefix,
11
+ resolveLocalizedString as resolveNavLabel,
12
+ splitNavigationByPosition,
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';
20
+
21
+ const TOP_BAR_MAX_HEIGHT = 42;
22
+
23
+ interface AppBarLayoutProps {
24
+ title?: string;
25
+ appIcon?: string;
26
+ logo?: string;
27
+ navigation: (NavigationItem | NavigationGroup)[];
28
+ }
29
+
30
+ const getExternalFaviconUrl = (url: string): string | null => {
31
+ try {
32
+ const parsed = new URL(url);
33
+ const hostname = parsed.hostname;
34
+ if (!hostname) return null;
35
+ return `https://icons.duckduckgo.com/ip3/${hostname}.ico`;
36
+ } catch {
37
+ return null;
38
+ }
39
+ };
40
+
41
+ const isAppIcon = (src: string) => src.startsWith('/icons/');
42
+
43
+ function resolveLocalizedLabel(
44
+ value: string | { en: string; fr: string; [key: string]: string },
45
+ lang: string,
46
+ ): string {
47
+ if (typeof value === 'string') return value;
48
+ return value[lang] || value.en || value.fr || Object.values(value)[0] || '';
49
+ }
50
+
51
+ /** End link: icon-only or first-letter badge with themed tooltip. */
52
+ function TopBarEndItem({ item, label }: { item: NavigationItem; label: string }) {
53
+ const pathPrefix = getNavPathPrefix(item);
54
+ const isOverlay = item.openIn === 'modal' || item.openIn === 'drawer';
55
+ const isExternal = item.openIn === 'external';
56
+ const location = useLocation();
57
+ const isActive =
58
+ !isOverlay &&
59
+ !isExternal &&
60
+ (location.pathname === pathPrefix || location.pathname.startsWith(`${pathPrefix}/`));
61
+
62
+ const faviconUrl = isExternal && !item.icon ? getExternalFaviconUrl(getEffectiveUrl(item)) : null;
63
+ const iconSrc = item.icon ?? faviconUrl ?? null;
64
+ const firstLetter = label ? label.charAt(0).toUpperCase() : '?';
65
+
66
+ const iconEl = iconSrc ? (
67
+ <img
68
+ src={iconSrc}
69
+ alt=""
70
+ className={cn(
71
+ 'size-5 shrink-0 rounded-sm object-cover',
72
+ isAppIcon(iconSrc) && 'opacity-90 dark:opacity-100 dark:invert',
73
+ )}
74
+ />
75
+ ) : (
76
+ <span
77
+ className="size-6 shrink-0 rounded-md bg-muted flex items-center justify-center text-xs font-medium text-muted-foreground"
78
+ aria-hidden
79
+ >
80
+ {firstLetter}
81
+ </span>
82
+ );
83
+
84
+ const buttonClass = cn(
85
+ 'flex items-center justify-center size-8 rounded-md transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
86
+ isActive
87
+ ? 'bg-sidebar-accent text-sidebar-accent-foreground'
88
+ : 'text-muted-foreground hover:bg-sidebar-accent/50 hover:text-sidebar-foreground',
89
+ );
90
+
91
+ const wrap = (node: ReactNode) => <AppBarTooltip label={label}>{node}</AppBarTooltip>;
92
+
93
+ if (item.openIn === 'modal') {
94
+ return wrap(
95
+ <button
96
+ type="button"
97
+ onClick={() => shellui.openModal(getEffectiveUrl(item))}
98
+ className={buttonClass}
99
+ aria-label={label}
100
+ >
101
+ {iconEl}
102
+ </button>,
103
+ );
104
+ }
105
+ if (item.openIn === 'drawer') {
106
+ return wrap(
107
+ <button
108
+ type="button"
109
+ onClick={() => shellui.openDrawer({ url: getEffectiveUrl(item), position: item.drawerPosition })}
110
+ className={buttonClass}
111
+ aria-label={label}
112
+ >
113
+ {iconEl}
114
+ </button>,
115
+ );
116
+ }
117
+ if (item.openIn === 'external') {
118
+ return wrap(
119
+ <a
120
+ href={getEffectiveUrl(item)}
121
+ target="_blank"
122
+ rel="noopener noreferrer"
123
+ className={buttonClass}
124
+ aria-label={label}
125
+ >
126
+ {iconEl}
127
+ </a>,
128
+ );
129
+ }
130
+ return wrap(
131
+ <Link
132
+ to={pathPrefix}
133
+ className={buttonClass}
134
+ aria-label={label}
135
+ >
136
+ {iconEl}
137
+ </Link>,
138
+ );
139
+ }
140
+
141
+ export function AppBarLayout({ title, logo, navigation }: AppBarLayoutProps) {
142
+ const { i18n } = useTranslation();
143
+ const location = useLocation();
144
+ const navigate = useNavigate();
145
+ const currentLanguage = i18n.language || 'en';
146
+
147
+ const { endNavItems, navigationItems, displayStartItems } = useMemo(() => {
148
+ const desktopNav = filterNavigationByViewport(navigation, 'desktop');
149
+ const { start, end } = splitNavigationByPosition(desktopNav);
150
+ const startItems = flattenNavigationItems(start).filter((i) => !i.hidden);
151
+ return {
152
+ endNavItems: flattenNavigationItems(end).filter((i) => !i.hidden),
153
+ navigationItems: flattenNavigationItems(navigation),
154
+ displayStartItems: withHomepageWhenNoRoot(startItems),
155
+ };
156
+ }, [navigation]);
157
+
158
+ useEffect(() => {
159
+ if (!title) return;
160
+ const pathname = location.pathname.replace(/^\/+|\/+$/g, '') || '';
161
+ const segment = pathname.split('/')[0];
162
+ if (!segment) {
163
+ const rootNavItem = navigationItems.find((item) => item.path === '' || item.path === '/');
164
+ document.title = rootNavItem
165
+ ? `${resolveLocalizedLabel(rootNavItem.label, currentLanguage)} | ${title}`
166
+ : title;
167
+ return;
168
+ }
169
+ const navItem = navigationItems.find((item) => item.path === segment);
170
+ if (navItem) {
171
+ const label = resolveLocalizedLabel(navItem.label, currentLanguage);
172
+ document.title = `${label} | ${title}`;
173
+ } else {
174
+ document.title = title;
175
+ }
176
+ }, [location.pathname, title, navigationItems, currentLanguage]);
177
+
178
+ const currentPathPrefix =
179
+ location.pathname === '/'
180
+ ? '/'
181
+ : `/${location.pathname.replace(/^\/+|\/+$/g, '').split('/')[0]}`;
182
+
183
+ return (
184
+ <LayoutProviders>
185
+ <OverlayShell navigationItems={navigationItems}>
186
+ <div className="flex flex-col h-screen overflow-hidden bg-background">
187
+ {/* Top bar: max 42px */}
188
+ <header
189
+ className="flex items-center gap-3 px-3 border-b border-border bg-sidebar-background shrink-0"
190
+ style={{ minHeight: 32, maxHeight: TOP_BAR_MAX_HEIGHT }}
191
+ data-layout="app-bar"
192
+ >
193
+ {/* Logo / title (home link) */}
194
+ <Link
195
+ to="/"
196
+ 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"
197
+ >
198
+ {logo && logo.trim() ? (
199
+ <img
200
+ src={logo}
201
+ alt={title || 'Logo'}
202
+ className="h-5 w-auto max-h-6 object-contain app-bar-logo m-1.5"
203
+ />
204
+ ) : title ? (
205
+ <span className="text-sm font-semibold truncate">{title}</span>
206
+ ) : null}
207
+ </Link>
208
+
209
+ {/* Start links: select menu (includes synthetic Homepage when nav has no "/" path) */}
210
+ {displayStartItems.length > 0 && (
211
+ <Select
212
+ className="h-8 max-w-[200px] text-sm leading-tight py-1.5 border-sidebar-border bg-sidebar-background"
213
+ value={currentPathPrefix}
214
+ onChange={(e) => {
215
+ const path = e.target.value;
216
+ if (path) {
217
+ navigate(path.startsWith('/') ? path : `/${path}`);
218
+ }
219
+ }}
220
+ >
221
+ {displayStartItems.map((item) => (
222
+ <option
223
+ key={item.path || 'root'}
224
+ value={getNavPathPrefix(item)}
225
+ >
226
+ {resolveNavLabel(item.label, currentLanguage) || item.path || 'Home'}
227
+ </option>
228
+ ))}
229
+ </Select>
230
+ )}
231
+
232
+ <div className="flex-1 min-w-0" />
233
+
234
+ {/* End links: icon-only or first letter + tooltip */}
235
+ {endNavItems.length > 0 && (
236
+ <TooltipProvider
237
+ delayDuration={200}
238
+ skipDelayDuration={0}
239
+ >
240
+ <div className="flex items-center gap-0.5 shrink-0">
241
+ {endNavItems.map((item) => (
242
+ <TopBarEndItem
243
+ key={item.path}
244
+ item={item}
245
+ label={resolveNavLabel(item.label, currentLanguage) || item.path || ''}
246
+ />
247
+ ))}
248
+ </div>
249
+ </TooltipProvider>
250
+ )}
251
+ </header>
252
+
253
+ <main className="flex-1 flex flex-col overflow-auto min-h-0">
254
+ <Outlet />
255
+ </main>
256
+ </div>
257
+ </OverlayShell>
258
+ </LayoutProviders>
259
+ );
260
+ }
@@ -11,6 +11,9 @@ const FullscreenLayout = lazy(() =>
11
11
  const WindowsLayout = lazy(() =>
12
12
  import('./WindowsLayout').then((m) => ({ default: m.WindowsLayout })),
13
13
  );
14
+ const AppBarLayout = lazy(() =>
15
+ import('./AppBarLayout').then((m) => ({ default: m.AppBarLayout })),
16
+ );
14
17
 
15
18
  interface AppLayoutProps {
16
19
  layout?: LayoutType;
@@ -50,6 +53,9 @@ export function AppLayout({
50
53
  } else if (effectiveLayout === 'windows') {
51
54
  LayoutComponent = WindowsLayout;
52
55
  layoutProps = { title, appIcon, logo, navigation };
56
+ } else if (effectiveLayout === 'app-bar') {
57
+ LayoutComponent = AppBarLayout;
58
+ layoutProps = { title, appIcon, logo, navigation };
53
59
  } else {
54
60
  LayoutComponent = DefaultLayout;
55
61
  layoutProps = { title, appIcon, logo, navigation };
@@ -22,6 +22,9 @@ import {
22
22
  filterNavigationByViewport,
23
23
  filterNavigationForSidebar,
24
24
  flattenNavigationItems,
25
+ getEffectiveUrl,
26
+ getNavPathPrefix,
27
+ HOMEPAGE_NAV_ITEM,
25
28
  resolveLocalizedString as resolveNavLabel,
26
29
  splitNavigationByPosition,
27
30
  } from './utils';
@@ -87,7 +90,7 @@ const NavigationContent = ({
87
90
 
88
91
  // Render a single nav item link or modal/drawer trigger
89
92
  const renderNavItem = (navItem: NavigationItem) => {
90
- const pathPrefix = `/${navItem.path}`;
93
+ const pathPrefix = getNavPathPrefix(navItem);
91
94
  const isOverlay = navItem.openIn === 'modal' || navItem.openIn === 'drawer';
92
95
  const isExternal = navItem.openIn === 'external';
93
96
  const isActive =
@@ -95,7 +98,7 @@ const NavigationContent = ({
95
98
  !isExternal &&
96
99
  (location.pathname === pathPrefix || location.pathname.startsWith(`${pathPrefix}/`));
97
100
  const itemLabel = resolveLocalizedString(navItem.label, currentLanguage);
98
- const faviconUrl = isExternal && !navItem.icon ? getExternalFaviconUrl(navItem.url) : null;
101
+ const faviconUrl = isExternal && !navItem.icon ? getExternalFaviconUrl(getEffectiveUrl(navItem)) : null;
99
102
  const iconSrc = navItem.icon ?? faviconUrl ?? null;
100
103
  const iconEl = iconSrc ? (
101
104
  <img
@@ -120,7 +123,7 @@ const NavigationContent = ({
120
123
  navItem.openIn === 'modal' ? (
121
124
  <button
122
125
  type="button"
123
- onClick={() => shellui.openModal(navItem.url)}
126
+ onClick={() => shellui.openModal(getEffectiveUrl(navItem))}
124
127
  className="flex items-center gap-2 w-full cursor-pointer text-left"
125
128
  >
126
129
  {content}
@@ -128,14 +131,14 @@ const NavigationContent = ({
128
131
  ) : navItem.openIn === 'drawer' ? (
129
132
  <button
130
133
  type="button"
131
- onClick={() => shellui.openDrawer({ url: navItem.url, position: navItem.drawerPosition })}
134
+ onClick={() => shellui.openDrawer({ url: getEffectiveUrl(navItem), position: navItem.drawerPosition })}
132
135
  className="flex items-center gap-2 w-full cursor-pointer text-left"
133
136
  >
134
137
  {content}
135
138
  </button>
136
139
  ) : navItem.openIn === 'external' ? (
137
140
  <a
138
- href={navItem.url}
141
+ href={getEffectiveUrl(navItem)}
139
142
  target="_blank"
140
143
  rel="noopener noreferrer"
141
144
  className="flex items-center gap-2 w-full"
@@ -144,7 +147,7 @@ const NavigationContent = ({
144
147
  </a>
145
148
  ) : (
146
149
  <Link
147
- to={`/${navItem.path}`}
150
+ to={pathPrefix}
148
151
  className="flex items-center gap-2 w-full"
149
152
  >
150
153
  {content}
@@ -273,7 +276,7 @@ const BottomNavItem = ({
273
276
  iconSrc: string | null;
274
277
  applyIconTheme: boolean;
275
278
  }) => {
276
- const pathPrefix = `/${item.path}`;
279
+ const pathPrefix = getNavPathPrefix(item);
277
280
  const content = (
278
281
  <span className="flex flex-col items-center justify-center gap-1 w-full min-w-0 max-w-full overflow-hidden">
279
282
  {iconSrc ? (
@@ -303,7 +306,7 @@ const BottomNavItem = ({
303
306
  return (
304
307
  <button
305
308
  type="button"
306
- onClick={() => shellui.openModal(item.url)}
309
+ onClick={() => shellui.openModal(getEffectiveUrl(item))}
307
310
  className={baseClass}
308
311
  >
309
312
  {content}
@@ -314,7 +317,7 @@ const BottomNavItem = ({
314
317
  return (
315
318
  <button
316
319
  type="button"
317
- onClick={() => shellui.openDrawer({ url: item.url, position: item.drawerPosition })}
320
+ onClick={() => shellui.openDrawer({ url: getEffectiveUrl(item), position: item.drawerPosition })}
318
321
  className={baseClass}
319
322
  >
320
323
  {content}
@@ -324,7 +327,7 @@ const BottomNavItem = ({
324
327
  if (item.openIn === 'external') {
325
328
  return (
326
329
  <a
327
- href={item.url}
330
+ href={getEffectiveUrl(item)}
328
331
  target="_blank"
329
332
  rel="noopener noreferrer"
330
333
  className={baseClass}
@@ -427,7 +430,7 @@ const HomeIcon = ({ className }: { className?: string }) => (
427
430
  </svg>
428
431
  );
429
432
 
430
- /** Mobile bottom nav: Home + nav items; More only when not all fit. Dynamic from width. */
433
+ /** Mobile bottom nav: Home + nav items; More only when not all fit. Dynamic from width. Reuses HOMEPAGE_NAV_ITEM for label. */
431
434
  const MobileBottomNav = ({
432
435
  items,
433
436
  currentLanguage,
@@ -477,7 +480,7 @@ const MobileBottomNav = ({
477
480
  }, [location.pathname]);
478
481
 
479
482
  const renderItem = (item: NavigationItem, index: number) => {
480
- const pathPrefix = `/${item.path}`;
483
+ const pathPrefix = getNavPathPrefix(item);
481
484
  const isOverlayOrExternal =
482
485
  item.openIn === 'modal' || item.openIn === 'drawer' || item.openIn === 'external';
483
486
  const isActive =
@@ -485,12 +488,12 @@ const MobileBottomNav = ({
485
488
  (location.pathname === pathPrefix || location.pathname.startsWith(`${pathPrefix}/`));
486
489
  const label = resolveNavLabel(item.label, currentLanguage);
487
490
  const faviconUrl =
488
- item.openIn === 'external' && !item.icon ? getExternalFaviconUrl(item.url) : null;
491
+ item.openIn === 'external' && !item.icon ? getExternalFaviconUrl(getEffectiveUrl(item)) : null;
489
492
  const iconSrc = item.icon ?? faviconUrl ?? null;
490
493
  const applyIconTheme = iconSrc ? isAppIcon(iconSrc) : false;
491
494
  return (
492
495
  <BottomNavItem
493
- key={`${item.path}-${item.url}-${index}`}
496
+ key={`${item.path}-${getEffectiveUrl(item)}-${index}`}
494
497
  item={item}
495
498
  label={label}
496
499
  isActive={isActive}
@@ -519,12 +522,14 @@ const MobileBottomNav = ({
519
522
  ? 'bg-sidebar-accent text-sidebar-accent-foreground [&_span]:text-sidebar-accent-foreground'
520
523
  : 'text-sidebar-foreground/80 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground [&_span]:inherit',
521
524
  )}
522
- aria-label="Home"
525
+ aria-label={resolveNavLabel(HOMEPAGE_NAV_ITEM.label, currentLanguage) || 'Home'}
523
526
  >
524
527
  <span className="size-4 shrink-0 flex items-center justify-center [&_svg]:text-current">
525
528
  <HomeIcon className="size-4" />
526
529
  </span>
527
- <span className="text-[11px] leading-tight">Home</span>
530
+ <span className="text-[11px] leading-tight">
531
+ {resolveNavLabel(HOMEPAGE_NAV_ITEM.label, currentLanguage) || 'Home'}
532
+ </span>
528
533
  </Link>
529
534
  {rowItems.map((item, i) => renderItem(item, i))}
530
535
  {hasMore && (
@@ -589,7 +594,10 @@ const DefaultLayoutContent = ({ title, logo, navigation }: DefaultLayoutProps) =
589
594
  const pathname = location.pathname.replace(/^\/+|\/+$/g, '') || '';
590
595
  const segment = pathname.split('/')[0];
591
596
  if (!segment) {
592
- document.title = title;
597
+ const rootNavItem = navigationItems.find((item) => item.path === '' || item.path === '/');
598
+ document.title = rootNavItem
599
+ ? `${resolveLocalizedLabel(rootNavItem.label, currentLanguage)} | ${title}`
600
+ : title;
593
601
  return;
594
602
  }
595
603
  const navItem = navigationItems.find((item) => item.path === segment);
@@ -9,7 +9,7 @@ import { Toaster } from '../../components/ui/sonner';
9
9
  import { ContentView } from '../../components/ContentView';
10
10
  import { useModal } from '../modal/ModalContext';
11
11
  import { useDrawer } from '../drawer/DrawerContext';
12
- import { resolveLocalizedString } from './utils';
12
+ import { getEffectiveUrl, getNavPathPrefix, normalizeUrlToPathname, resolveLocalizedString } from './utils';
13
13
 
14
14
  interface OverlayShellProps {
15
15
  navigationItems: NavigationItem[];
@@ -60,9 +60,10 @@ export function OverlayShell({ navigationItems, children }: OverlayShellProps) {
60
60
  const isHomepage = pathname === '/' || pathname === '';
61
61
  const isAllowed =
62
62
  isHomepage ||
63
- navigationItems.some(
64
- (item) => pathname === `/${item.path}` || pathname.startsWith(`/${item.path}/`),
65
- );
63
+ navigationItems.some((item) => {
64
+ const pathPrefix = getNavPathPrefix(item);
65
+ return pathname === pathPrefix || pathname.startsWith(`${pathPrefix}/`);
66
+ });
66
67
  if (isAllowed) {
67
68
  navigate(pathname || '/');
68
69
  } else {
@@ -82,14 +83,16 @@ export function OverlayShell({ navigationItems, children }: OverlayShellProps) {
82
83
  {children}
83
84
  <Dialog
84
85
  open={isOpen}
85
- onOpenChange={closeModal}
86
+ onOpenChange={(open) => !open && closeModal()}
86
87
  >
87
88
  <DialogContent className="max-w-4xl w-full h-[80vh] max-h-[680px] flex flex-col p-0 overflow-hidden">
88
89
  {modalUrl ? (
89
90
  <>
90
91
  <DialogTitle className="sr-only">
91
92
  {resolveLocalizedString(
92
- navigationItems.find((item) => item.url === modalUrl)?.label,
93
+ navigationItems.find(
94
+ (item) => normalizeUrlToPathname(getEffectiveUrl(item)) === normalizeUrlToPathname(modalUrl),
95
+ )?.label,
93
96
  currentLanguage,
94
97
  )}
95
98
  </DialogTitle>
@@ -104,7 +107,11 @@ export function OverlayShell({ navigationItems, children }: OverlayShellProps) {
104
107
  url={modalUrl}
105
108
  pathPrefix="settings"
106
109
  ignoreMessages={true}
107
- navItem={navigationItems.find((item) => item.url === modalUrl) as NavigationItem}
110
+ navItem={
111
+ navigationItems.find(
112
+ (item) => normalizeUrlToPathname(getEffectiveUrl(item)) === normalizeUrlToPathname(modalUrl),
113
+ ) ?? undefined
114
+ }
108
115
  />
109
116
  </div>
110
117
  </>
@@ -146,7 +153,11 @@ export function OverlayShell({ navigationItems, children }: OverlayShellProps) {
146
153
  url={drawerUrl}
147
154
  pathPrefix="settings"
148
155
  ignoreMessages={true}
149
- navItem={navigationItems.find((item) => item.url === drawerUrl) as NavigationItem}
156
+ navItem={
157
+ navigationItems.find(
158
+ (item) => normalizeUrlToPathname(getEffectiveUrl(item)) === normalizeUrlToPathname(drawerUrl),
159
+ ) ?? undefined
160
+ }
150
161
  />
151
162
  </div>
152
163
  ) : (
@@ -11,6 +11,8 @@ import { shellui } from '@shellui/sdk';
11
11
  import type { NavigationItem, NavigationGroup } from '../config/types';
12
12
  import {
13
13
  flattenNavigationItems,
14
+ getEffectiveUrl,
15
+ getNavPathPrefix,
14
16
  resolveLocalizedString as resolveNavLabel,
15
17
  splitNavigationByPosition,
16
18
  } from './utils';
@@ -70,7 +72,7 @@ function getMaximizedBounds(): WindowState['bounds'] {
70
72
  }
71
73
 
72
74
  function buildFinalUrl(baseUrl: string, path: string, pathname: string): string {
73
- const pathPrefix = `/${path}`;
75
+ const pathPrefix = getNavPathPrefix({ path } as NavigationItem);
74
76
  const subPath = pathname.length > pathPrefix.length ? pathname.slice(pathPrefix.length + 1) : '';
75
77
  if (!subPath) return baseUrl;
76
78
  const base = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
@@ -540,7 +542,7 @@ export function WindowsLayout({
540
542
  const label =
541
543
  typeof item.label === 'string' ? item.label : resolveNavLabel(item.label, currentLanguage);
542
544
  const faviconUrl =
543
- item.openIn === 'external' && !item.icon ? getExternalFaviconUrl(item.url) : null;
545
+ item.openIn === 'external' && !item.icon ? getExternalFaviconUrl(getEffectiveUrl(item)) : null;
544
546
  const icon = item.icon ?? faviconUrl ?? null;
545
547
  const id = genId();
546
548
  const bounds = {
@@ -554,8 +556,8 @@ export function WindowsLayout({
554
556
  {
555
557
  id,
556
558
  path: item.path,
557
- pathname: `/${item.path}`,
558
- baseUrl: item.url,
559
+ pathname: getNavPathPrefix(item),
560
+ baseUrl: getEffectiveUrl(item),
559
561
  label,
560
562
  icon,
561
563
  bounds,
@@ -608,17 +610,17 @@ export function WindowsLayout({
608
610
  const handleNavClick = useCallback(
609
611
  (item: NavigationItem) => {
610
612
  if (item.openIn === 'modal') {
611
- shellui.openModal(item.url);
613
+ shellui.openModal(getEffectiveUrl(item));
612
614
  setStartMenuOpen(false);
613
615
  return;
614
616
  }
615
617
  if (item.openIn === 'drawer') {
616
- shellui.openDrawer({ url: item.url, position: item.drawerPosition });
618
+ shellui.openDrawer({ url: getEffectiveUrl(item), position: item.drawerPosition });
617
619
  setStartMenuOpen(false);
618
620
  return;
619
621
  }
620
622
  if (item.openIn === 'external') {
621
- window.open(item.url, '_blank', 'noopener,noreferrer');
623
+ window.open(getEffectiveUrl(item), '_blank', 'noopener,noreferrer');
622
624
  setStartMenuOpen(false);
623
625
  return;
624
626
  }
@@ -708,7 +710,7 @@ export function WindowsLayout({
708
710
  : resolveNavLabel(item.label, currentLanguage);
709
711
  const icon =
710
712
  item.icon ??
711
- (item.openIn === 'external' ? getExternalFaviconUrl(item.url) : null);
713
+ (item.openIn === 'external' ? getExternalFaviconUrl(getEffectiveUrl(item)) : null);
712
714
  return (
713
715
  <button
714
716
  key={item.path}
@@ -790,7 +792,7 @@ export function WindowsLayout({
790
792
  : resolveNavLabel(item.label, currentLanguage);
791
793
  const icon =
792
794
  item.icon ??
793
- (item.openIn === 'external' ? getExternalFaviconUrl(item.url) : null);
795
+ (item.openIn === 'external' ? getExternalFaviconUrl(getEffectiveUrl(item)) : null);
794
796
  return (
795
797
  <button
796
798
  key={item.path}
@@ -1,4 +1,34 @@
1
1
  import type { NavigationItem, NavigationGroup, LocalizedString } from '../config/types';
2
+ import urls from '../../constants/urls';
3
+
4
+ /** Path prefix for a nav item: "/" for root (path '' or '/'), otherwise "/{path}". */
5
+ export function getNavPathPrefix(item: NavigationItem): string {
6
+ return item.path === '/' || item.path === '' ? '/' : `/${item.path}`;
7
+ }
8
+
9
+ /** Effective URL for a nav item: url if set, otherwise app-path URL for component-based items. */
10
+ export function getEffectiveUrl(item: NavigationItem): string {
11
+ if (item.url != null && item.url !== '') {
12
+ return item.url;
13
+ }
14
+ const base = typeof window !== 'undefined' ? window.location.origin : '';
15
+ const path = item.path === '/' || item.path === '' ? 'home' : item.path;
16
+ return `${base}${urls.appPath}/${path}`;
17
+ }
18
+
19
+ /** Normalize a URL to pathname for comparison (handles full URLs and path-only). */
20
+ export function normalizeUrlToPathname(url: string): string {
21
+ if (!url || typeof url !== 'string') return '';
22
+ const s = url.trim();
23
+ if (s.startsWith('http://') || s.startsWith('https://') || s.startsWith('//')) {
24
+ try {
25
+ return new URL(s, 'http://localhost').pathname.replace(/\/+$/, '') || '/';
26
+ } catch {
27
+ return s.startsWith('/') ? s.replace(/\/+$/, '') || '/' : `/${s}`.replace(/\/+$/, '') || '/';
28
+ }
29
+ }
30
+ return (s.startsWith('/') ? s : `/${s}`).replace(/\/+$/, '') || '/';
31
+ }
2
32
 
3
33
  /** Resolve a localized string to a single string for the given language. */
4
34
  export function resolveLocalizedString(value: LocalizedString | undefined, lang: string): string {
@@ -74,6 +104,20 @@ export function filterNavigationForSidebar(
74
104
  .filter((item): item is NavigationItem | NavigationGroup => item !== null);
75
105
  }
76
106
 
107
+ /** Synthetic homepage nav item: used when there is no root (path '' or '/') in the list, so users can navigate to "/". Reused by app-bar and sidebar mobile. */
108
+ export const HOMEPAGE_NAV_ITEM: NavigationItem = {
109
+ path: '/',
110
+ label: { en: 'Home', fr: 'Accueil' },
111
+ url: '/',
112
+ };
113
+
114
+ /** If there is no root item (path '' or '/') in the list, prepend a synthetic Homepage item. Use in app-bar and anywhere that needs a "Home" entry when nav has no "/" path. */
115
+ export function withHomepageWhenNoRoot(items: NavigationItem[]): NavigationItem[] {
116
+ const hasRoot = items.some((i) => i.path === '' || i.path === '/');
117
+ if (hasRoot) return items;
118
+ return [HOMEPAGE_NAV_ITEM, ...items];
119
+ }
120
+
77
121
  /** Split navigation by position: start (main content) and end (footer). */
78
122
  export function splitNavigationByPosition(navigation: (NavigationItem | NavigationGroup)[]): {
79
123
  start: (NavigationItem | NavigationGroup)[];