@shellui/core 0.2.0-alpha.4 → 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.
- package/package.json +2 -2
- package/src/app.tsx +2 -2
- package/src/components/ContentView.tsx +70 -135
- package/src/components/LoadingOverlay.tsx +5 -1
- package/src/components/ui/sidebar.tsx +2 -124
- package/src/constants/loading.ts +2 -0
- package/src/features/config/types.ts +2 -0
- package/src/features/layouts/AppLayout.tsx +22 -19
- package/src/features/layouts/LayoutFallback.tsx +8 -0
- package/src/features/layouts/OverlayShell.tsx +23 -9
- 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 +29 -1
- package/src/features/layouts/{WindowsLayout.tsx → windows/WindowsLayout.tsx} +199 -204
- package/src/features/settings/SettingsView.tsx +178 -181
- 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 +9 -4
- 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 +18 -16
- package/src/components/ViewRoute.tsx +0 -48
- 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 -660
- package/src/features/layouts/LayoutProviders.tsx +0 -20
- /package/src/{router → routes}/router.tsx +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { useEffect, type ReactNode } from 'react';
|
|
2
|
-
import { useNavigate } from 'react-router';
|
|
1
|
+
import { useEffect, useRef, type ReactNode } from 'react';
|
|
2
|
+
import { useLocation, useNavigate } from 'react-router';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
import { shellui } from '@shellui/sdk';
|
|
5
5
|
import type { NavigationItem } from '../config/types';
|
|
@@ -9,16 +9,18 @@ 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 { useNavigationItems } from '../../routes/hooks/useNavigationItems';
|
|
12
13
|
import { getNavPathPrefix, resolveLocalizedString } from './utils';
|
|
13
14
|
|
|
14
15
|
interface OverlayShellProps {
|
|
15
|
-
navigationItems: NavigationItem[];
|
|
16
16
|
children: ReactNode;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/** Renders modal, drawer and toaster overlays and handles SHELLUI_OPEN_MODAL / SHELLUI_NAVIGATE. */
|
|
20
|
-
export
|
|
20
|
+
export const OverlayShell = ({ children }: OverlayShellProps) => {
|
|
21
|
+
const location = useLocation();
|
|
21
22
|
const navigate = useNavigate();
|
|
23
|
+
const { navigationItems } = useNavigationItems();
|
|
22
24
|
const { isOpen, modalUrl, closeModal } = useModal();
|
|
23
25
|
const {
|
|
24
26
|
isOpen: isDrawerOpen,
|
|
@@ -30,6 +32,17 @@ export function OverlayShell({ navigationItems, children }: OverlayShellProps) {
|
|
|
30
32
|
const { t, i18n } = useTranslation('common');
|
|
31
33
|
const currentLanguage = i18n.language || 'en';
|
|
32
34
|
|
|
35
|
+
// Close modal and drawer when app URL changes (navigation, back button) so overlay content stays url-specific
|
|
36
|
+
const locationKeyRef = useRef(location.pathname + location.search + location.hash);
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const currentKey = location.pathname + location.search + location.hash;
|
|
39
|
+
if (locationKeyRef.current !== currentKey) {
|
|
40
|
+
closeModal();
|
|
41
|
+
closeDrawer();
|
|
42
|
+
locationKeyRef.current = currentKey;
|
|
43
|
+
}
|
|
44
|
+
}, [location.pathname, location.search, location.hash, closeModal, closeDrawer]);
|
|
45
|
+
|
|
33
46
|
useEffect(() => {
|
|
34
47
|
const cleanup = shellui.addMessageListener('SHELLUI_OPEN_MODAL', () => {
|
|
35
48
|
closeDrawer();
|
|
@@ -43,7 +56,11 @@ export function OverlayShell({ navigationItems, children }: OverlayShellProps) {
|
|
|
43
56
|
const rawUrl = payload?.url;
|
|
44
57
|
if (typeof rawUrl !== 'string' || !rawUrl.trim()) return;
|
|
45
58
|
|
|
59
|
+
closeModal();
|
|
60
|
+
closeDrawer();
|
|
61
|
+
|
|
46
62
|
let pathname: string;
|
|
63
|
+
|
|
47
64
|
if (rawUrl.startsWith('http://') || rawUrl.startsWith('https://')) {
|
|
48
65
|
try {
|
|
49
66
|
pathname = new URL(rawUrl).pathname;
|
|
@@ -54,9 +71,6 @@ export function OverlayShell({ navigationItems, children }: OverlayShellProps) {
|
|
|
54
71
|
pathname = rawUrl.startsWith('/') ? rawUrl : `/${rawUrl}`;
|
|
55
72
|
}
|
|
56
73
|
|
|
57
|
-
closeModal();
|
|
58
|
-
closeDrawer();
|
|
59
|
-
|
|
60
74
|
const isHomepage = pathname === '/' || pathname === '';
|
|
61
75
|
const isAllowed =
|
|
62
76
|
isHomepage ||
|
|
@@ -77,7 +91,6 @@ export function OverlayShell({ navigationItems, children }: OverlayShellProps) {
|
|
|
77
91
|
});
|
|
78
92
|
return () => cleanup();
|
|
79
93
|
}, [navigate, closeModal, closeDrawer, navigationItems, t]);
|
|
80
|
-
|
|
81
94
|
return (
|
|
82
95
|
<>
|
|
83
96
|
{children}
|
|
@@ -169,4 +182,5 @@ export function OverlayShell({ navigationItems, children }: OverlayShellProps) {
|
|
|
169
182
|
<Toaster />
|
|
170
183
|
</>
|
|
171
184
|
);
|
|
172
|
-
}
|
|
185
|
+
};
|
|
186
|
+
export default OverlayShell;
|
|
@@ -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
|
+
}
|