@shellui/core 0.2.0-beta.0 → 0.2.0-beta.2
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 +2 -2
- package/src/app.tsx +1 -1
- package/src/components/ContentView.tsx +22 -61
- package/src/components/LoadingOverlay.tsx +1 -1
- package/src/components/ui/sidebar.tsx +2 -124
- package/src/features/layouts/AppLayout.tsx +22 -19
- package/src/features/layouts/LayoutFallback.tsx +8 -0
- package/src/features/layouts/OverlayShell.tsx +21 -40
- package/src/features/layouts/{AppBarLayout.tsx → appbar/AppBarLayout.tsx} +72 -78
- package/src/features/layouts/{FullscreenLayout.tsx → fullscreen/FullscreenLayout.tsx} +5 -11
- package/src/features/layouts/sidebar/BottomNavItem.tsx +88 -0
- package/src/features/layouts/sidebar/MobileBottomNav.tsx +168 -0
- package/src/features/layouts/sidebar/NavigationContent.tsx +159 -0
- package/src/features/layouts/sidebar/SidebarIcons.tsx +93 -0
- package/src/features/layouts/sidebar/SidebarInner.tsx +48 -0
- package/src/features/layouts/sidebar/SidebarLayout.tsx +86 -0
- package/src/features/layouts/sidebar/sidebarUtils.ts +23 -0
- package/src/features/layouts/sidebar/types.ts +8 -0
- package/src/features/layouts/utils.ts +1 -1
- package/src/features/layouts/{WindowsLayout.tsx → windows/WindowsLayout.tsx} +199 -204
- package/src/features/settings/SettingsView.tsx +177 -180
- package/src/{components → routes/components}/HomeView.tsx +1 -1
- package/src/{components → routes/components}/IndexRoute.tsx +4 -4
- package/src/routes/components/NavigationItemRoute.tsx +19 -0
- package/src/{components → routes/components}/NotFoundView.tsx +3 -3
- package/src/{components → routes/components}/RouteErrorBoundary.tsx +1 -1
- package/src/routes/components/RouteFallback.tsx +8 -0
- package/src/routes/hooks/useNavigationItems.ts +84 -0
- package/src/{router → routes}/routes.tsx +10 -18
- package/src/components/ViewRoute.tsx +0 -74
- package/src/dist/CookiePreferencesView.52b5aec8.js +0 -1182
- package/src/dist/CookiePreferencesView.52b5aec8.js.map +0 -1
- package/src/dist/DefaultLayout.045a82ff.js +0 -1964
- package/src/dist/DefaultLayout.045a82ff.js.map +0 -1
- package/src/dist/DefaultLayout.4454f259.js +0 -4414
- package/src/dist/DefaultLayout.4454f259.js.map +0 -1
- package/src/dist/FullscreenLayout.555c4987.js +0 -1054
- package/src/dist/FullscreenLayout.555c4987.js.map +0 -1
- package/src/dist/HomeView.ddfa7b68.js +0 -771
- package/src/dist/HomeView.ddfa7b68.js.map +0 -1
- package/src/dist/NotFoundView.c75be4f1.js +0 -811
- package/src/dist/NotFoundView.c75be4f1.js.map +0 -1
- package/src/dist/SettingsView.052b03a6.js +0 -4965
- package/src/dist/SettingsView.052b03a6.js.map +0 -1
- package/src/dist/ViewRoute.e6e3b142.js +0 -1042
- package/src/dist/ViewRoute.e6e3b142.js.map +0 -1
- package/src/dist/WindowsLayout.08724167.js +0 -1762
- package/src/dist/WindowsLayout.08724167.js.map +0 -1
- package/src/dist/esm.f0d741e6.js +0 -29520
- package/src/dist/esm.f0d741e6.js.map +0 -1
- package/src/dist/favicon.4367ac1e.svg +0 -14
- package/src/dist/index.parcel.36d65383.js +0 -54089
- package/src/dist/index.parcel.36d65383.js.map +0 -1
- package/src/dist/index.parcel.ca6d8a47.css +0 -3493
- package/src/dist/index.parcel.ca6d8a47.css.map +0 -1
- package/src/dist/index.parcel.html +0 -88
- package/src/features/layouts/DefaultLayout.tsx +0 -670
- package/src/features/layouts/LayoutProviders.tsx +0 -20
- /package/src/{constants.ts → constants/loading.ts} +0 -0
- /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 '
|
|
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 '
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
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
|
-
<
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
{logo
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
{
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
234
|
+
<div className="flex-1 min-w-0" />
|
|
239
235
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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 '
|
|
5
|
-
import { flattenNavigationItems } from '
|
|
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
|
-
<
|
|
48
|
-
<
|
|
49
|
-
|
|
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
|
+
}
|