From e5e8077c364ca8c01add69aa6fdab96fc2ecb0b6 Mon Sep 17 00:00:00 2001 From: David Cornejo Date: Thu, 10 Sep 2026 15:50:37 -0600 Subject: [PATCH 1/2] Add NavigationTabs popover --- .../NavigationHeader.tsx} | 26 ++-- .../Package/Navigation/NavigationTabs.tsx | 133 ++++++++++++++++++ .../Package/Navigation/useNavigationTabs.ts | 98 +++++++++++++ scenes/PackageCodeScene.tsx | 4 +- scenes/PackageOverviewScene.tsx | 4 +- scenes/PackageScoreScene.tsx | 4 +- scenes/PackageVersionsScene.tsx | 4 +- 7 files changed, 254 insertions(+), 19 deletions(-) rename components/Package/{DetailsNavigation.tsx => Navigation/NavigationHeader.tsx} (79%) create mode 100644 components/Package/Navigation/NavigationTabs.tsx create mode 100644 components/Package/Navigation/useNavigationTabs.ts 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 24a0ec053..546b2945d 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 000000000..b8e3e07d9 --- /dev/null +++ b/components/Package/Navigation/NavigationTabs.tsx @@ -0,0 +1,133 @@ +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 000000000..02f77d660 --- /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 faefdad3c..cfc88ad2f 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 f3344079e..6f9f09b0f 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 116b3f039..6f2640d92 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" /> - + From 6caa3e1b96eabfa53f8af3e30dfe35bc764a0b78 Mon Sep 17 00:00:00 2001 From: Bartosz Kaszubowski Date: Fri, 11 Sep 2026 08:36:48 +0200 Subject: [PATCH 2/2] address pointer prop warning, set trigger cursor, move gap to style --- .../Package/Navigation/NavigationTabs.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/components/Package/Navigation/NavigationTabs.tsx b/components/Package/Navigation/NavigationTabs.tsx index b8e3e07d9..1efb5c50b 100644 --- a/components/Package/Navigation/NavigationTabs.tsx +++ b/components/Package/Navigation/NavigationTabs.tsx @@ -50,8 +50,10 @@ export default function NavigationTabs({ tabs }: Props) { style={tw`relative flex-1 overflow-hidden`} onLayout={onNavigationLayout}> {tabs.map((tab, index) => ( @@ -62,14 +64,16 @@ export default function NavigationTabs({ tabs }: Props) { - + style={[ + tw`flex-1 flex-row items-center`, + measuring && tw`opacity-0`, + measuring ? tw`pointer-events-none` : tw`pointer-events-auto`, + { rowGap: TABS_GAP }, + ]}> {tabs.slice(0, visible).map(tab => ( ))} - {hiddenTabs.length > 0 && ( +

More