diff --git a/components/InputKeyHint.tsx b/components/InputKeyHint.tsx new file mode 100644 index 000000000..e4cac0303 --- /dev/null +++ b/components/InputKeyHint.tsx @@ -0,0 +1,30 @@ +import { Label } from '~/common/styleguide'; +import tw from '~/util/tailwind'; + +type Props = { + content: { + key?: string; + label?: string; + }[]; +}; + +export const focusHintLabel = tw`font-light text-palette-gray4`; +export const focusHintKey = tw`min-w-6 rounded-[3px] bg-palette-gray5 px-1 py-[3px] text-center tracking-[0.75px] text-tertiary dark:bg-powder`; + +export default function InputKeyHint({ content }: Props) { + return content.map(entry => { + if ('key' in entry) { + return ( + + ); + } else if ('label' in entry) { + return ( + + ); + } + }); +} diff --git a/components/Package/VersionDownloadsChart/index.tsx b/components/Package/VersionDownloadsChart/index.tsx index 21527fc0a..f7152ab26 100644 --- a/components/Package/VersionDownloadsChart/index.tsx +++ b/components/Package/VersionDownloadsChart/index.tsx @@ -1,13 +1,13 @@ import { ParentSize } from '@visx/responsive'; import { Axis, BarSeries, Grid, Tooltip, XYChart } from '@visx/xychart'; import { keyBy } from 'es-toolkit/array'; -import { omit } from 'es-toolkit/object'; import { useRouter } from 'next/router'; import { useEffect, useMemo, useState } from 'react'; import { Text, View } from 'react-native'; import { Label } from '~/common/styleguide'; import { type NpmPerVersionDownloads, type NpmRegistryData } from '~/types'; +import { replaceQueryParam } from '~/util/queryParams'; import { formatNumberToString, pluralize } from '~/util/strings'; import tw from '~/util/tailwind'; @@ -91,22 +91,10 @@ export default function VersionDownloadsChart({ npmDownloads, registryData }: Pr } setMode(nextMode); - - const queryParams = omit(router.query, [CHART_MODE_QUERY_PARAM]); - - void router.replace( - { - pathname: router.pathname, - query: - nextMode === DEFAULT_CHART_MODE - ? queryParams - : { ...queryParams, [CHART_MODE_QUERY_PARAM]: nextMode }, - }, - undefined, - { - shallow: true, - scroll: false, - } + replaceQueryParam( + router, + CHART_MODE_QUERY_PARAM, + nextMode === DEFAULT_CHART_MODE ? undefined : nextMode ); } diff --git a/components/Package/VersionsSection.tsx b/components/Package/VersionsSection.tsx new file mode 100644 index 000000000..0a111f32c --- /dev/null +++ b/components/Package/VersionsSection.tsx @@ -0,0 +1,160 @@ +import { useRouter } from 'next/router'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { type ColorValue, TextInput, View } from 'react-native'; +import { useDebouncedCallback } from 'use-debounce'; + +import { Caption, H6, Label, useLayout } from '~/common/styleguide'; +import { Button } from '~/components/Button'; +import { Search } from '~/components/Icons'; +import InputKeyHint from '~/components/InputKeyHint'; +import { type NpmPerVersionDownloads, type NpmRegistryData } from '~/types'; +import { parseQueryParams, replaceQueryParam } from '~/util/queryParams'; +import { pluralize } from '~/util/strings'; +import tw from '~/util/tailwind'; + +import VersionBox from './VersionBox'; + +const VERSIONS_TO_SHOW = 25; + +type Props = { + registryData: NpmRegistryData; + npmDownloads?: NpmPerVersionDownloads; +}; + +export default function VersionsSection({ registryData, npmDownloads }: Props) { + const router = useRouter(); + const { isSmallScreen } = useLayout(); + + const [shouldShowAll, setShowAll] = useState(false); + const [isInputFocused, setInputFocused] = useState(false); + const inputRef = useRef(null); + + const routeVersionSearch = useMemo( + () => parseQueryParams(router.query).versionSearch?.toLowerCase() ?? '', + [router.query] + ); + const [versionSearch, setVersionSearch] = useState(routeVersionSearch); + + useEffect(() => { + setVersionSearch(currentVersionSearch => + currentVersionSearch === routeVersionSearch ? currentVersionSearch : routeVersionSearch + ); + }, [routeVersionSearch]); + + useEffect(() => setShowAll(false), [versionSearch]); + + const versions = useMemo( + () => + Object.entries(registryData.versions).sort( + (a, b) => -registryData.time[a[1].version].localeCompare(registryData.time[b[1].version]) + ), + [registryData] + ); + + const filteredVersions = useMemo( + () => + versionSearch + ? versions.filter(([version, versionData]) => + [version, versionData.version].some(value => + value.toLowerCase().includes(versionSearch) + ) + ) + : versions, + [versionSearch, versions] + ); + const visibleVersions = useMemo( + () => filteredVersions.slice(0, shouldShowAll ? filteredVersions.length : VERSIONS_TO_SHOW), + [filteredVersions, shouldShowAll] + ); + + const updateVersionSearchQuery = useDebouncedCallback((versionSearch: string) => { + replaceQueryParam(router, 'versionSearch', versionSearch); + }, 200); + + return ( + <> +
+ Versions + +
+ + + + + + { + setVersionSearch(text); + updateVersionSearchQuery(text.trim()); + }} + onKeyPress={event => { + if ('key' in event) { + if (inputRef.current && event.key === 'Escape') { + if (versionSearch) { + event.preventDefault(); + inputRef.current.clear(); + setVersionSearch(''); + replaceQueryParam(router, 'versionSearch', undefined); + } else { + inputRef.current.blur(); + } + } + } + }} + onFocus={() => setInputFocused(true)} + onBlur={() => setInputFocused(false)} + placeholder="Filter versions…" + style={tw`h-11 flex-1 rounded-lg bg-palette-gray1 p-3 pl-11 text-base text-black dark:bg-dark dark:text-white`} + placeholderTextColor={tw`text-palette-gray4`.color as ColorValue} + /> + {!isSmallScreen && ( + + {isInputFocused && ( + 0 ? 'clear' : 'blur'}` }, + ]} + /> + )} + + )} + + + + {visibleVersions.length ? ( + visibleVersions.map(([version, versionData]) => ( + + )) + ) : ( + + + + )} + + {!shouldShowAll && filteredVersions.length > VERSIONS_TO_SHOW && ( + + )} + + ); +} diff --git a/components/Search.tsx b/components/Search.tsx index 9b46a864e..0a54588b6 100644 --- a/components/Search.tsx +++ b/components/Search.tsx @@ -3,7 +3,8 @@ import { useEffect, useEffectEvent, useMemo, useRef, useState } from 'react'; import { type ColorValue, type StyleProp, TextInput, View, type ViewStyle } from 'react-native'; import { useDebouncedCallback } from 'use-debounce'; -import { Label, P, useLayout } from '~/common/styleguide'; +import { P, useLayout } from '~/common/styleguide'; +import InputKeyHint from '~/components/InputKeyHint'; import { type Query } from '~/types'; import isAppleDevice from '~/util/isAppleDevice'; import tw from '~/util/tailwind'; @@ -61,9 +62,6 @@ export default function Search({ query, total, style, isHomePage = false }: Prop void replace(urlWithQuery('/packages', { search, offset: undefined })); } - const focusHintLabel = tw`font-light text-palette-gray4`; - const focusHintKey = tw`min-w-6 rounded-[3px] bg-palette-gray5 px-1 py-[3px] text-center tracking-[0.75px] text-tertiary dark:bg-powder`; - return ( <> @@ -123,28 +121,23 @@ export default function Search({ query, total, style, isHomePage = false }: Prop {!isSmallScreen && ( {isInputFocused ? ( - <> - - {isHomePage ? ( - <> - - - - ) : ( - <> - - - - )} - + isHomePage ? ( + + ) : ( + 0 ? 'clear' : 'blur'}` }, + ]} + /> + ) ) : ( - <> - - - - + )} )} diff --git a/pages/api/libraries/index.ts b/pages/api/libraries/index.ts index 348f7fe31..d97799af4 100644 --- a/pages/api/libraries/index.ts +++ b/pages/api/libraries/index.ts @@ -5,7 +5,7 @@ import data from '~/assets/data.json'; import { getBookmarksFromCookie } from '~/context/BookmarksContext'; import { type DataAssetType, type QueryOrder, type SortedDataType } from '~/types'; import { NUM_PER_PAGE } from '~/util/Constants'; -import { parseQueryParams } from '~/util/parseQueryParams'; +import { parseQueryParams } from '~/util/queryParams'; import { handleFilterLibraries } from '~/util/search'; import * as Sorting from '~/util/sorting'; diff --git a/pages/api/library/index.ts b/pages/api/library/index.ts index a5d7447d6..4521df3fe 100644 --- a/pages/api/library/index.ts +++ b/pages/api/library/index.ts @@ -2,7 +2,7 @@ import { type NextApiRequest, type NextApiResponse } from 'next'; import data from '~/assets/data.json'; import { type DataAssetType } from '~/types'; -import { parseQueryParams } from '~/util/parseQueryParams'; +import { parseQueryParams } from '~/util/queryParams'; const DATASET = data as DataAssetType; diff --git a/pages/api/proxy/npm-stat.ts b/pages/api/proxy/npm-stat.ts index c94a04410..e52d6340d 100644 --- a/pages/api/proxy/npm-stat.ts +++ b/pages/api/proxy/npm-stat.ts @@ -2,7 +2,7 @@ import { type NextApiRequest, type NextApiResponse } from 'next'; import { NEXT_10M_CACHE_HEADER } from '~/util/Constants'; import { TimeRange } from '~/util/datetime'; -import { parseQueryParams } from '~/util/parseQueryParams'; +import { parseQueryParams } from '~/util/queryParams'; export default async function handler(req: NextApiRequest, res: NextApiResponse) { const { name } = parseQueryParams(req.query); diff --git a/pages/package/[name]/[scopedName]/index.tsx b/pages/package/[name]/[scopedName]/index.tsx index e92a617c9..a30c04e7b 100644 --- a/pages/package/[name]/[scopedName]/index.tsx +++ b/pages/package/[name]/[scopedName]/index.tsx @@ -5,7 +5,7 @@ import PackageOverviewScene from '~/scenes/PackageOverviewScene'; import { type PackageOverviewPageProps } from '~/types/pages'; import { EMPTY_PACKAGE_DATA, NEXT_10M_CACHE_HEADER } from '~/util/Constants'; import { getPackagePageErrorProps } from '~/util/getPackagePageErrorProps'; -import { parseQueryParams } from '~/util/parseQueryParams'; +import { parseQueryParams } from '~/util/queryParams'; import { ssrFetch } from '~/util/SSRFetch'; export default function ScopedOverviewPage({ diff --git a/pages/package/[name]/[scopedName]/score.tsx b/pages/package/[name]/[scopedName]/score.tsx index 6bf98f919..5c0240eae 100644 --- a/pages/package/[name]/[scopedName]/score.tsx +++ b/pages/package/[name]/[scopedName]/score.tsx @@ -5,7 +5,7 @@ import PackageScoreScene from '~/scenes/PackageScoreScene'; import { type PackageScorePageProps } from '~/types/pages'; import { EMPTY_PACKAGE_DATA } from '~/util/Constants'; import { getPackagePageErrorProps } from '~/util/getPackagePageErrorProps'; -import { parseQueryParams } from '~/util/parseQueryParams'; +import { parseQueryParams } from '~/util/queryParams'; import { ssrFetch } from '~/util/SSRFetch'; export default function ScorePage({ apiData, packageName, errorMessage }: PackageScorePageProps) { diff --git a/pages/package/[name]/[scopedName]/versions.tsx b/pages/package/[name]/[scopedName]/versions.tsx index 1d76e068c..1c25538a0 100644 --- a/pages/package/[name]/[scopedName]/versions.tsx +++ b/pages/package/[name]/[scopedName]/versions.tsx @@ -5,7 +5,7 @@ import PackageVersionsScene from '~/scenes/PackageVersionsScene'; import { type PackageVersionsPageProps } from '~/types/pages'; import { EMPTY_PACKAGE_DATA, NEXT_10M_CACHE_HEADER } from '~/util/Constants'; import { getPackagePageErrorProps } from '~/util/getPackagePageErrorProps'; -import { parseQueryParams } from '~/util/parseQueryParams'; +import { parseQueryParams } from '~/util/queryParams'; import { ssrFetch } from '~/util/SSRFetch'; export default function ScopedVersionsPage({ diff --git a/pages/package/[name]/index.tsx b/pages/package/[name]/index.tsx index 6e52d9b5b..39f0f6838 100644 --- a/pages/package/[name]/index.tsx +++ b/pages/package/[name]/index.tsx @@ -5,7 +5,7 @@ import PackageOverviewScene from '~/scenes/PackageOverviewScene'; import { type PackageOverviewPageProps } from '~/types/pages'; import { EMPTY_PACKAGE_DATA, NEXT_10M_CACHE_HEADER } from '~/util/Constants'; import { getPackagePageErrorProps } from '~/util/getPackagePageErrorProps'; -import { parseQueryParams } from '~/util/parseQueryParams'; +import { parseQueryParams } from '~/util/queryParams'; import { ssrFetch } from '~/util/SSRFetch'; export default function OverviewPage({ diff --git a/pages/package/[name]/score.tsx b/pages/package/[name]/score.tsx index cd2e414ca..f06d5edeb 100644 --- a/pages/package/[name]/score.tsx +++ b/pages/package/[name]/score.tsx @@ -5,7 +5,7 @@ import PackageScoreScene from '~/scenes/PackageScoreScene'; import { type PackageScorePageProps } from '~/types/pages'; import { EMPTY_PACKAGE_DATA } from '~/util/Constants'; import { getPackagePageErrorProps } from '~/util/getPackagePageErrorProps'; -import { parseQueryParams } from '~/util/parseQueryParams'; +import { parseQueryParams } from '~/util/queryParams'; import { ssrFetch } from '~/util/SSRFetch'; export default function ScorePage({ apiData, packageName, errorMessage }: PackageScorePageProps) { diff --git a/pages/package/[name]/versions.tsx b/pages/package/[name]/versions.tsx index eb88b9851..63f640abb 100644 --- a/pages/package/[name]/versions.tsx +++ b/pages/package/[name]/versions.tsx @@ -5,7 +5,7 @@ import PackageVersionsScene from '~/scenes/PackageVersionsScene'; import { type PackageVersionsPageProps } from '~/types/pages'; import { EMPTY_PACKAGE_DATA, NEXT_10M_CACHE_HEADER } from '~/util/Constants'; import { getPackagePageErrorProps } from '~/util/getPackagePageErrorProps'; -import { parseQueryParams } from '~/util/parseQueryParams'; +import { parseQueryParams } from '~/util/queryParams'; import { ssrFetch } from '~/util/SSRFetch'; export default function VersionsPage({ diff --git a/scenes/PackageVersionsScene.tsx b/scenes/PackageVersionsScene.tsx index 7e0ab70e5..69a82385e 100644 --- a/scenes/PackageVersionsScene.tsx +++ b/scenes/PackageVersionsScene.tsx @@ -1,20 +1,18 @@ -import { useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { View } from 'react-native'; -import { Caption, H6, Label, useLayout } from '~/common/styleguide'; -import { Button } from '~/components/Button'; +import { H6, Label, useLayout } from '~/common/styleguide'; import ContentContainer from '~/components/ContentContainer'; import DetailsNavigation from '~/components/Package/DetailsNavigation'; import NotFound from '~/components/Package/NotFound'; import PackageHeader from '~/components/Package/PackageHeader'; import VersionBox from '~/components/Package/VersionBox'; import VersionDownloadsChart from '~/components/Package/VersionDownloadsChart'; +import VersionsSection from '~/components/Package/VersionsSection'; import PageMeta from '~/components/PageMeta'; import { type PackageVersionsPageProps } from '~/types/pages'; import tw from '~/util/tailwind'; -const VERSIONS_TO_SHOW = 25; - export default function PackageVersionsScene({ apiData, registryData, @@ -22,21 +20,12 @@ export default function PackageVersionsScene({ npmDownloads, }: PackageVersionsPageProps) { const { isSmallScreen } = useLayout(); - const [shouldShowAll, setShowAll] = useState(false); const library = useMemo( () => apiData.libraries.find(lib => lib.npmPkg === packageName), [apiData.libraries, packageName] ); - const versions = useMemo( - () => - Object.entries(registryData?.versions ?? {}).sort( - (a, b) => -registryData!.time[a[1].version].localeCompare(registryData!.time[b[1].version]) - ), - [registryData] - ); - const taggedVersions = useMemo( () => Object.entries(registryData?.['dist-tags'] ?? {}).sort( @@ -85,38 +74,17 @@ export default function PackageVersionsScene({ ) : null}
Tagged versions
- {taggedVersions.map(([label, versionData]) => { - return ( - - ); - })} - -
Versions
- - {versions - .slice(0, shouldShowAll ? versions.length : VERSIONS_TO_SHOW) - .map(([version, versionData]) => { - return ( - - ); - })} + {taggedVersions.map(([label, versionData]) => ( + + ))} - {!shouldShowAll && versions.length > VERSIONS_TO_SHOW ? ( - - ) : null} +
diff --git a/util/parseQueryParams.ts b/util/parseQueryParams.ts deleted file mode 100644 index 5be1e5d10..000000000 --- a/util/parseQueryParams.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function parseQueryParams(params: Partial>) { - return Object.fromEntries( - Object.entries(params).map(([key, val]) => [key, Array.isArray(val) ? val[0] : val]) - ); -} diff --git a/util/queryParams.ts b/util/queryParams.ts new file mode 100644 index 000000000..d687bd01a --- /dev/null +++ b/util/queryParams.ts @@ -0,0 +1,29 @@ +import { omit } from 'es-toolkit/object'; +import { type NextRouter } from 'next/router'; + +import { CHART_MODE_QUERY_PARAM } from '~/components/Package/VersionDownloadsChart/utils'; + +export function parseQueryParams(params: Partial>) { + return Object.fromEntries( + Object.entries(params).map(([key, val]) => [ + key, + Array.isArray(val) ? val[0]?.trim() : val?.trim(), + ]) + ); +} + +export function replaceQueryParam(router: NextRouter, paramName: string, paramValue?: string) { + const queryParams = omit(router.query, [CHART_MODE_QUERY_PARAM, paramName]); + + void router.replace( + { + pathname: router.pathname, + query: paramValue ? { ...queryParams, [paramName]: paramValue } : queryParams, + }, + undefined, + { + shallow: true, + scroll: false, + } + ); +}