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" /> - +