diff --git a/components/Package/DetailsNavigation.tsx b/components/Package/Navigation/NavigationHeader.tsx
similarity index 79%
rename from components/Package/DetailsNavigation.tsx
rename to components/Package/Navigation/NavigationHeader.tsx
index 24a0ec05..546b2945 100644
--- a/components/Package/DetailsNavigation.tsx
+++ b/components/Package/Navigation/NavigationHeader.tsx
@@ -5,7 +5,7 @@ import { A, Label, useLayout } from '~/common/styleguide';
import ContentContainer from '~/components/ContentContainer';
import { WarningIcon } from '~/components/Icons';
import Navigation from '~/components/Navigation';
-import NavigationTab from '~/components/NavigationTab';
+import NavigationTabs from '~/components/Package/Navigation/NavigationTabs';
import { type LibraryType } from '~/types';
import { strippedBackground } from '~/util/style';
import tw from '~/util/tailwind';
@@ -14,10 +14,21 @@ type Props = {
library: LibraryType;
};
-export default function DetailsNavigation({ library }: Props) {
+export default function NavigationHeader({ library }: Props) {
const { isSmallScreen } = useLayout();
const alternativesLength = library.alternatives?.length ?? 0;
+ const tabs = [
+ { title: 'Overview', path: `/package/${library.npmPkg}` },
+ {
+ title: 'Versions',
+ counter: library.npm?.versionsCount,
+ path: `/package/${library.npmPkg}/versions`,
+ },
+ { title: 'Code', path: `/package/${library.npmPkg}/code` },
+ { title: 'Score', path: `/package/${library.npmPkg}/score` },
+ ];
+
return (
) : undefined
}>
-
-
-
-
-
+
+
);
diff --git a/components/Package/Navigation/NavigationTabs.tsx b/components/Package/Navigation/NavigationTabs.tsx
new file mode 100644
index 00000000..1efb5c50
--- /dev/null
+++ b/components/Package/Navigation/NavigationTabs.tsx
@@ -0,0 +1,137 @@
+import * as Popover from '@radix-ui/react-popover';
+import { useRouter } from 'next/router';
+import { useState } from 'react';
+import { View } from 'react-native';
+
+import { A, HoverEffect, P } from '~/common/styleguide';
+import { ArrowIcon } from '~/components/Icons';
+import NavigationTab from '~/components/NavigationTab';
+import EntityCounter from '~/components/Package/EntityCounter';
+import { useNavigationTabs } from '~/components/Package/Navigation/useNavigationTabs';
+import SelectorItemHoverEffect from '~/components/Selector/SelectorItemHoverEffect';
+import tw from '~/util/tailwind';
+
+type Tab = {
+ title: string;
+ path: string;
+ counter?: number | string;
+};
+
+type Props = {
+ tabs: Tab[];
+};
+
+const TABS_GAP = 8;
+
+export default function NavigationTabs({ tabs }: Props) {
+ const [open, setOpen] = useState(false);
+ const router = useRouter();
+
+ const currentPath = decodeURIComponent(router.asPath.split('?')[0]);
+ const activeIndex = tabs.findIndex(tab => decodeURIComponent(tab.path) === currentPath);
+
+ const {
+ visible,
+ measuring,
+ navigationRef,
+ tabRef,
+ triggerRef,
+ onNavigationLayout,
+ onTabLayout,
+ onTriggerLayout,
+ } = useNavigationTabs(tabs.length, TABS_GAP);
+
+ const hiddenTabs = tabs.slice(visible);
+ const isTriggerActive = activeIndex >= visible;
+
+ return (
+
+
+ {tabs.map((tab, index) => (
+
+
+
+ ))}
+
+
+
+
+
+ {tabs.slice(0, visible).map(tab => (
+
+ ))}
+ {hiddenTabs.length > 0 && (
+
+
+
+
+
+
+
+
+
+
+
+ {hiddenTabs.map((tab, index) => (
+ setOpen(false)}>
+
+
+ {tab.title}
+
+ {!!tab.counter && (
+
+ )}
+
+
+ ))}
+
+
+
+
+ )}
+
+
+ );
+}
+
+function MoreTrigger({ active, open }: { active: boolean; open: boolean }) {
+ return (
+
+ More
+
+
+ );
+}
diff --git a/components/Package/Navigation/useNavigationTabs.ts b/components/Package/Navigation/useNavigationTabs.ts
new file mode 100644
index 00000000..02f77d66
--- /dev/null
+++ b/components/Package/Navigation/useNavigationTabs.ts
@@ -0,0 +1,98 @@
+import { useEffectEvent, useLayoutEffect, useRef, useState } from 'react';
+import { type LayoutChangeEvent, type ViewInstance } from 'react-native';
+
+export function useNavigationTabs(tabs: number, gap: number) {
+ const [navigationWidth, setNavigationWidth] = useState(null);
+ const [triggerWidth, setTriggerWidth] = useState(null);
+ const [tabWidths, setTabWidths] = useState(null);
+
+ const navigationNodeRef = useRef(null);
+ const triggerNodeRef = useRef(null);
+ const tabRefs = useRef<(ViewInstance | null)[]>([]);
+
+ const measuredWidths = useRef(null);
+ const measuredCount = useRef(0);
+
+ function updateTabWidth(index: number, width: number) {
+ const widths = (measuredWidths.current ??= Array.from({ length: tabs }, () => 0));
+ if (widths[index] === width) {
+ return;
+ }
+ if (!widths[index]) {
+ measuredCount.current += 1;
+ }
+ widths[index] = width;
+ if (measuredCount.current === tabs) {
+ setTabWidths([...widths]);
+ }
+ }
+
+ const onLayout = useEffectEvent(() => {
+ if (navigationNodeRef.current) {
+ const { width } = navigationNodeRef.current.getBoundingClientRect();
+ setNavigationWidth(width);
+ }
+ if (triggerNodeRef.current) {
+ const { width } = triggerNodeRef.current.getBoundingClientRect();
+ setTriggerWidth(width);
+ }
+ tabRefs.current.forEach((tab, index) => {
+ if (tab) {
+ const { width } = tab.getBoundingClientRect();
+ updateTabWidth(index, width);
+ }
+ });
+ });
+
+ useLayoutEffect(() => {
+ onLayout();
+ }, []);
+
+ const measuring = navigationWidth === null || tabWidths === null || triggerWidth === null;
+
+ let visible = tabs;
+
+ if (!measuring) {
+ const totalWidth = tabWidths.reduce((sum, width) => sum + width, 0) + gap * (tabs - 1);
+
+ if (totalWidth > navigationWidth) {
+ visible = 0;
+ let usedWidth = 0;
+ for (let index = 0; index < tabs; index++) {
+ const withTab = usedWidth + tabWidths[index] + (index > 0 ? gap : 0);
+ const hasTriggerAfter = index < tabs - 1;
+ const withTrigger = withTab + (hasTriggerAfter ? gap + triggerWidth : 0);
+ if (withTrigger > navigationWidth) {
+ break;
+ }
+ usedWidth = withTab;
+ visible = index + 1;
+ }
+ }
+ }
+
+ return {
+ visible,
+ measuring,
+ navigationRef: (node: ViewInstance | null) => {
+ navigationNodeRef.current = node;
+ },
+ tabRef: (index: number) => (node: ViewInstance | null) => {
+ tabRefs.current[index] = node;
+ },
+ triggerRef: (node: ViewInstance | null) => {
+ triggerNodeRef.current = node;
+ },
+ onNavigationLayout: (event: LayoutChangeEvent) =>
+ setNavigationWidth(event.nativeEvent.layout.width),
+ onTabLayout:
+ (index: number) =>
+ ({
+ nativeEvent: {
+ layout: { width },
+ },
+ }: LayoutChangeEvent) =>
+ updateTabWidth(index, width),
+ onTriggerLayout: (event: LayoutChangeEvent) => setTriggerWidth(event.nativeEvent.layout.width),
+ };
+}
diff --git a/scenes/PackageCodeScene.tsx b/scenes/PackageCodeScene.tsx
index faefdad3..cfc88ad2 100644
--- a/scenes/PackageCodeScene.tsx
+++ b/scenes/PackageCodeScene.tsx
@@ -8,7 +8,7 @@ import ContentContainer from '~/components/ContentContainer';
import CodeBrowser from '~/components/Package/CodeBrowser';
import { type CodeBrowserSettingsType } from '~/components/Package/CodeBrowser/CodeBrowserSettings';
import PackageVersionSelector from '~/components/Package/CodeBrowser/PackageVersionSelector';
-import DetailsNavigation from '~/components/Package/DetailsNavigation';
+import NavigationHeader from '~/components/Package/Navigation/NavigationHeader';
import NotFound from '~/components/Package/NotFound';
import PackageHeader from '~/components/Package/PackageHeader';
import PageMeta from '~/components/PageMeta';
@@ -152,7 +152,7 @@ export default function PackageCodeScene({ apiData, packageName }: PackageCodePa
description="See package directory score details"
path="package"
/>
-
+
-
+
diff --git a/scenes/PackageScoreScene.tsx b/scenes/PackageScoreScene.tsx
index f3344079..6f9f09b0 100644
--- a/scenes/PackageScoreScene.tsx
+++ b/scenes/PackageScoreScene.tsx
@@ -3,7 +3,7 @@ import { View } from 'react-native';
import { A, Caption, H6Section, useLayout } from '~/common/styleguide';
import ContentContainer from '~/components/ContentContainer';
import { DirectoryScore } from '~/components/Library/DirectoryScore';
-import DetailsNavigation from '~/components/Package/DetailsNavigation';
+import NavigationHeader from '~/components/Package/Navigation/NavigationHeader';
import NotFound from '~/components/Package/NotFound';
import PackageHeader from '~/components/Package/PackageHeader';
import PageMeta from '~/components/PageMeta';
@@ -28,7 +28,7 @@ export default function PackageScoreScene({ apiData, packageName }: PackageScore
description="See package directory score details"
path="package"
/>
-
+
diff --git a/scenes/PackageVersionsScene.tsx b/scenes/PackageVersionsScene.tsx
index 116b3f03..6f2640d9 100644
--- a/scenes/PackageVersionsScene.tsx
+++ b/scenes/PackageVersionsScene.tsx
@@ -4,7 +4,7 @@ import { View } from 'react-native';
import { H6Section } from '~/common/styleguide';
import ContentContainer from '~/components/ContentContainer';
import ChartSectionHeader from '~/components/Package/Charts/ChartSectionHeader';
-import DetailsNavigation from '~/components/Package/DetailsNavigation';
+import NavigationHeader from '~/components/Package/Navigation/NavigationHeader';
import NotFound from '~/components/Package/NotFound';
import PackageHeader from '~/components/Package/PackageHeader';
import ThreeDotsLoader from '~/components/Package/ThreeDotsLoader';
@@ -71,7 +71,7 @@ export default function PackageVersionsScene({
description={`See ${library.npmPkg} package published versions information and metadata`}
path="package"
/>
-
+