@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.
Files changed (61) hide show
  1. package/package.json +2 -2
  2. package/src/app.tsx +2 -2
  3. package/src/components/ContentView.tsx +70 -135
  4. package/src/components/LoadingOverlay.tsx +5 -1
  5. package/src/components/ui/sidebar.tsx +2 -124
  6. package/src/constants/loading.ts +2 -0
  7. package/src/features/config/types.ts +2 -0
  8. package/src/features/layouts/AppLayout.tsx +22 -19
  9. package/src/features/layouts/LayoutFallback.tsx +8 -0
  10. package/src/features/layouts/OverlayShell.tsx +23 -9
  11. package/src/features/layouts/{AppBarLayout.tsx → appbar/AppBarLayout.tsx} +72 -78
  12. package/src/features/layouts/{FullscreenLayout.tsx → fullscreen/FullscreenLayout.tsx} +5 -11
  13. package/src/features/layouts/sidebar/BottomNavItem.tsx +88 -0
  14. package/src/features/layouts/sidebar/MobileBottomNav.tsx +168 -0
  15. package/src/features/layouts/sidebar/NavigationContent.tsx +159 -0
  16. package/src/features/layouts/sidebar/SidebarIcons.tsx +93 -0
  17. package/src/features/layouts/sidebar/SidebarInner.tsx +48 -0
  18. package/src/features/layouts/sidebar/SidebarLayout.tsx +86 -0
  19. package/src/features/layouts/sidebar/sidebarUtils.ts +23 -0
  20. package/src/features/layouts/sidebar/types.ts +8 -0
  21. package/src/features/layouts/utils.ts +29 -1
  22. package/src/features/layouts/{WindowsLayout.tsx → windows/WindowsLayout.tsx} +199 -204
  23. package/src/features/settings/SettingsView.tsx +178 -181
  24. package/src/{components → routes/components}/HomeView.tsx +1 -1
  25. package/src/{components → routes/components}/IndexRoute.tsx +4 -4
  26. package/src/routes/components/NavigationItemRoute.tsx +19 -0
  27. package/src/{components → routes/components}/NotFoundView.tsx +9 -4
  28. package/src/{components → routes/components}/RouteErrorBoundary.tsx +1 -1
  29. package/src/routes/components/RouteFallback.tsx +8 -0
  30. package/src/routes/hooks/useNavigationItems.ts +84 -0
  31. package/src/{router → routes}/routes.tsx +18 -16
  32. package/src/components/ViewRoute.tsx +0 -48
  33. package/src/dist/CookiePreferencesView.52b5aec8.js +0 -1182
  34. package/src/dist/CookiePreferencesView.52b5aec8.js.map +0 -1
  35. package/src/dist/DefaultLayout.045a82ff.js +0 -1964
  36. package/src/dist/DefaultLayout.045a82ff.js.map +0 -1
  37. package/src/dist/DefaultLayout.4454f259.js +0 -4414
  38. package/src/dist/DefaultLayout.4454f259.js.map +0 -1
  39. package/src/dist/FullscreenLayout.555c4987.js +0 -1054
  40. package/src/dist/FullscreenLayout.555c4987.js.map +0 -1
  41. package/src/dist/HomeView.ddfa7b68.js +0 -771
  42. package/src/dist/HomeView.ddfa7b68.js.map +0 -1
  43. package/src/dist/NotFoundView.c75be4f1.js +0 -811
  44. package/src/dist/NotFoundView.c75be4f1.js.map +0 -1
  45. package/src/dist/SettingsView.052b03a6.js +0 -4965
  46. package/src/dist/SettingsView.052b03a6.js.map +0 -1
  47. package/src/dist/ViewRoute.e6e3b142.js +0 -1042
  48. package/src/dist/ViewRoute.e6e3b142.js.map +0 -1
  49. package/src/dist/WindowsLayout.08724167.js +0 -1762
  50. package/src/dist/WindowsLayout.08724167.js.map +0 -1
  51. package/src/dist/esm.f0d741e6.js +0 -29520
  52. package/src/dist/esm.f0d741e6.js.map +0 -1
  53. package/src/dist/favicon.4367ac1e.svg +0 -14
  54. package/src/dist/index.parcel.36d65383.js +0 -54089
  55. package/src/dist/index.parcel.36d65383.js.map +0 -1
  56. package/src/dist/index.parcel.ca6d8a47.css +0 -3493
  57. package/src/dist/index.parcel.ca6d8a47.css.map +0 -1
  58. package/src/dist/index.parcel.html +0 -88
  59. package/src/features/layouts/DefaultLayout.tsx +0 -660
  60. package/src/features/layouts/LayoutProviders.tsx +0 -20
  61. /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 function OverlayShell({ navigationItems, children }: OverlayShellProps) {
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 '../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
+ }