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,
+ }
+ );
+}