From 12b37012807da28e1330d8939e1345e9f26f0a33 Mon Sep 17 00:00:00 2001
From: Bartosz Kaszubowski
Date: Thu, 26 Mar 2026 16:01:59 +0100
Subject: [PATCH 1/5] add quick search with inline results to the home page
---
components/CompatibilityTags.tsx | 79 +++---
components/Icons/index.tsx | 101 +++++++-
components/Library/NewArchitectureTag.tsx | 5 +-
components/Navigation.tsx | 14 +-
components/QuickSearch.tsx | 282 ++++++++++++++++++++++
components/QuickSearchResult.tsx | 65 +++++
components/Search.tsx | 105 ++++----
components/Tag.tsx | 7 +-
scenes/HomeScene.tsx | 8 +-
styles/styles.css | 21 ++
10 files changed, 573 insertions(+), 114 deletions(-)
create mode 100644 components/QuickSearch.tsx
create mode 100644 components/QuickSearchResult.tsx
diff --git a/components/CompatibilityTags.tsx b/components/CompatibilityTags.tsx
index 5b432fca2..fc3123f27 100644
--- a/components/CompatibilityTags.tsx
+++ b/components/CompatibilityTags.tsx
@@ -1,6 +1,6 @@
import { View } from 'react-native';
-import { A } from '~/common/styleguide';
+import { A, useLayout } from '~/common/styleguide';
import { type LibraryType } from '~/types';
import tw from '~/util/tailwind';
@@ -11,9 +11,12 @@ import Tooltip from './Tooltip';
type Props = {
library: LibraryType;
+ small?: boolean;
};
-export default function CompatibilityTags({ library }: Props) {
+export default function CompatibilityTags({ library, small }: Props) {
+ const { isSmallScreen } = useLayout();
+
const platforms = [
library.android ? 'Android' : null,
library.ios ? 'iOS' : null,
@@ -25,12 +28,13 @@ export default function CompatibilityTags({ library }: Props) {
].filter(Boolean);
return (
-
+
{library.dev ? (
) : null}
{library.template ? (
@@ -38,50 +42,53 @@ export default function CompatibilityTags({ library }: Props) {
label="Template"
tagStyle={tw`border-[#f5c6e8] bg-[#fce1f5] dark:border-[#52213e] dark:bg-[#37172e]`}
icon={null}
+ small={small}
/>
) : null}
- {!library.dev && !library.template && }
+ {!library.dev && !library.template && }
{platforms.map(platform =>
platform ? (
) : null
)}
- {(library.expoGo ?? library.fireos ?? library.vegaos ?? library.horizon) && (
-
-
-
- }>
- Additional information
-
-
- {library.expoGo && - Works with Expo Go
}
- {library.fireos && - Works with Fire OS
}
- {library.horizon && - Works with Meta Horizon OS
}
- {library.vegaos && typeof library.vegaos === 'boolean' && - Works with Vega OS
}
- {library.vegaos && typeof library.vegaos === 'string' && (
- -
- Works with Vega OS
-
-
- (via dedicated support package)
-
-
- )}
-
-
- )}
+ {!(small && isSmallScreen) &&
+ (library.expoGo ?? library.fireos ?? library.vegaos ?? library.horizon) && (
+
+
+
+ }>
+ Additional information
+
+
+ {library.expoGo && - Works with Expo Go
}
+ {library.fireos && - Works with Fire OS
}
+ {library.horizon && - Works with Meta Horizon OS
}
+ {library.vegaos && typeof library.vegaos === 'boolean' && - Works with Vega OS
}
+ {library.vegaos && typeof library.vegaos === 'string' && (
+ -
+ Works with Vega OS
+
+
+ (via dedicated support package)
+
+
+ )}
+
+
+ )}
);
}
diff --git a/components/Icons/index.tsx b/components/Icons/index.tsx
index 6c000e309..c971ca007 100644
--- a/components/Icons/index.tsx
+++ b/components/Icons/index.tsx
@@ -1251,7 +1251,7 @@ export function FundingGitHub({ width = 24, height = 24, style }: IconProps) {
export function RSS({ width = 24, height = 24, style }: IconProps) {
return (
+ );
+}
+
+export function Spinner({ width = 24, height = 24, style }: IconProps) {
+ return (
+
);
}
diff --git a/components/Library/NewArchitectureTag.tsx b/components/Library/NewArchitectureTag.tsx
index 3e45cbed8..bd106e983 100644
--- a/components/Library/NewArchitectureTag.tsx
+++ b/components/Library/NewArchitectureTag.tsx
@@ -11,9 +11,10 @@ import tw from '~/util/tailwind';
type Props = {
library: LibraryType;
+ small?: boolean;
};
-export function NewArchitectureTag({ library }: Props) {
+export function NewArchitectureTag({ library, small = false }: Props) {
const status = getNewArchSupportStatus(library);
const icon = getTagIcon(status);
@@ -38,6 +39,7 @@ export function NewArchitectureTag({ library }: Props) {
@@ -49,6 +51,7 @@ export function NewArchitectureTag({ library }: Props) {
}
icon={icon}
tagStyle={getTagColor(status)}
+ small={small}
/>
diff --git a/components/Navigation.tsx b/components/Navigation.tsx
index 713223413..d7c81d144 100644
--- a/components/Navigation.tsx
+++ b/components/Navigation.tsx
@@ -30,15 +30,17 @@ export default function Navigation({
{header ?? (
-
+
+
+
;
+};
+
+const RESULTS_LIMIT = 5;
+
+export default function QuickSearch({ style }: Props) {
+ const [isInputFocused, setInputFocused] = useState(false);
+ const [activeResultIndex, setActiveResultIndex] = useState(null);
+ const [search, setSearch] = useState('');
+
+ const isApple = useMemo(() => isAppleDevice(), []);
+ const inputRef = useRef(null);
+
+ const { push } = useRouter();
+ const { isSmallScreen } = useLayout();
+
+ const { data, isLoading } = useSWR(
+ search.length ? `/api/libraries?search=${search}&limit=${RESULTS_LIMIT}` : undefined,
+ (url: string) => fetch(url).then(res => res.json()),
+ {
+ revalidateOnFocus: false,
+ }
+ );
+ const libraries = useMemo(() => data?.libraries ?? [], [data?.libraries]);
+ const hasResults = Boolean(data?.total && data.total > 0);
+ const shouldShowResults = search.length > 0 && isInputFocused;
+
+ useEffect(() => {
+ if (isApple !== null) {
+ document.addEventListener('keydown', keyDownListener, false);
+ return () => document.removeEventListener('keydown', keyDownListener);
+ }
+ }, [isApple]);
+
+ useEffect(() => {
+ if (!shouldShowResults || libraries.length === 0) {
+ setActiveResultIndex(null);
+ return;
+ }
+
+ if (activeResultIndex !== null && activeResultIndex >= libraries.length) {
+ setActiveResultIndex(libraries.length - 1);
+ }
+ }, [activeResultIndex, libraries.length, shouldShowResults]);
+
+ const keyDownListener = useEffectEvent((event: KeyboardEvent) => {
+ if (event.key === 'k' && (isApple ? event.metaKey : event.ctrlKey)) {
+ event.preventDefault();
+ inputRef.current?.focus();
+ }
+ });
+
+ const typingCallback = useDebouncedCallback((text: string) => {
+ setSearch(text);
+ }, 200);
+
+ const handleSearchChange = useCallback(
+ (text: string) => {
+ setActiveResultIndex(null);
+ typingCallback(text);
+ },
+ [typingCallback]
+ );
+
+ const handleInputFocus = useCallback(() => {
+ setInputFocused(true);
+ }, []);
+
+ const handleInputBlur = useCallback(() => {
+ setInputFocused(false);
+ setActiveResultIndex(null);
+ }, []);
+
+ const handleResultHoverChange = useCallback((index: number | null) => {
+ setActiveResultIndex(index);
+ }, []);
+
+ const handleArrowDownPress = useCallback(() => {
+ setActiveResultIndex(currentIndex => {
+ if (libraries.length === 0) {
+ return null;
+ }
+
+ if (currentIndex === null) {
+ return 0;
+ }
+
+ return Math.min(currentIndex + 1, libraries.length - 1);
+ });
+ }, [libraries.length]);
+
+ const handleArrowUpPress = useCallback(() => {
+ setActiveResultIndex(currentIndex => {
+ if (libraries.length === 0) {
+ return null;
+ }
+
+ if (currentIndex === null) {
+ return libraries.length - 1;
+ }
+
+ return Math.max(currentIndex - 1, 0);
+ });
+ }, [libraries.length]);
+
+ const handleResultSelect = useCallback(
+ async (npmPkg: string) => {
+ setInputFocused(false);
+ setActiveResultIndex(null);
+ await push(urlWithQuery(`/package/${npmPkg}`));
+ },
+ [push]
+ );
+
+ return (
+ <>
+
+
+
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+ {
+ if ('key' in event) {
+ if (typeof event.key !== 'string') {
+ return;
+ }
+
+ if (event.key === 'ArrowDown') {
+ if (shouldShowResults && hasResults) {
+ event.preventDefault();
+ handleArrowDownPress();
+ }
+ }
+
+ if (event.key === 'ArrowUp') {
+ if (shouldShowResults && hasResults) {
+ event.preventDefault();
+ handleArrowUpPress();
+ }
+ }
+
+ if (event.key === 'Enter') {
+ event.preventDefault();
+
+ if (activeResultIndex !== null && libraries[activeResultIndex]) {
+ void handleResultSelect(libraries[activeResultIndex].npmPkg);
+ return;
+ }
+
+ void push(urlWithQuery('/packages', { search }));
+ }
+
+ if (
+ event.key === 'k' &&
+ (('metaKey' in event && event.metaKey) || ('ctrlKey' in event && event.ctrlKey))
+ ) {
+ event.preventDefault();
+ }
+
+ if (inputRef.current && event.key === 'Escape') {
+ if (activeResultIndex !== null) {
+ event.preventDefault();
+ setActiveResultIndex(null);
+ return;
+ }
+
+ if (search) {
+ event.preventDefault();
+ inputRef.current.clear();
+ setSearch('');
+ } else {
+ inputRef.current.blur();
+ }
+ }
+ }
+ }}
+ onFocus={handleInputFocus}
+ onBlur={handleInputBlur}
+ onChangeText={handleSearchChange}
+ placeholder="Search libraries..."
+ style={tw`h-12.5 font-sans pr-30 flex flex-1 rounded-md border-2 border-palette-gray5 bg-palette-gray6 p-4 pl-11 text-xl text-white -outline-offset-2 dark:border-default dark:bg-dark`}
+ placeholderTextColor={tw`text-palette-gray4`.color as ColorValue}
+ />
+ {!isSmallScreen && (
+
+ {isInputFocused ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ {shouldShowResults && (
+
+ {hasResults && (
+
+ {libraries.map((lib, index) => (
+
+ ))}
+
+ )}
+ {hasResults && (data?.total ?? 0) > RESULTS_LIMIT && (
+
+
+
+ )}
+ {!hasResults && (
+
+ )}
+
+ )}
+
+ >
+ );
+}
diff --git a/components/QuickSearchResult.tsx b/components/QuickSearchResult.tsx
new file mode 100644
index 000000000..f03fdac08
--- /dev/null
+++ b/components/QuickSearchResult.tsx
@@ -0,0 +1,65 @@
+import { memo, useCallback } from 'react';
+import { Pressable, View } from 'react-native';
+
+import { Caption, Label, useLayout } from '~/common/styleguide';
+import CompatibilityTags from '~/components/CompatibilityTags';
+import { type LibraryType } from '~/types';
+import tw from '~/util/tailwind';
+
+type Props = {
+ index: number;
+ isActive: boolean;
+ library: LibraryType;
+ onHoverChange: (index: number | null) => void;
+ onSelect: (npmPkg: string) => Promise;
+};
+
+function QuickSearchResult({ index, isActive, library, onHoverChange, onSelect }: Props) {
+ const { isSmallScreen } = useLayout();
+
+ const handleHoverIn = useCallback(() => {
+ onHoverChange(index);
+ }, [index, onHoverChange]);
+
+ const handleHoverOut = useCallback(() => {
+ onHoverChange(null);
+ }, [onHoverChange]);
+
+ const handlePress = useCallback(async () => {
+ await onSelect(library.npmPkg);
+ }, [library.npmPkg, onSelect]);
+
+ return (
+ event.preventDefault()}
+ onHoverIn={handleHoverIn}
+ onHoverOut={handleHoverOut}
+ style={[
+ tw`max-w-full flex-row items-center justify-between gap-x-3 gap-y-1.5 py-1 pl-4 pr-3`,
+ isSmallScreen && tw`flex-wrap`,
+ isActive && tw`bg-palette-gray2 dark:bg-palette-gray6`,
+ ]}>
+
+ {library.npmPkg}
+
+
+
+
+ );
+}
+
+function arePropsEqual(previousProps: Props, nextProps: Props) {
+ return (
+ previousProps.index === nextProps.index &&
+ previousProps.isActive === nextProps.isActive &&
+ previousProps.library === nextProps.library &&
+ previousProps.onHoverChange === nextProps.onHoverChange &&
+ previousProps.onSelect === nextProps.onSelect
+ );
+}
+
+export default memo(QuickSearchResult, arePropsEqual);
diff --git a/components/Search.tsx b/components/Search.tsx
index 0a54588b6..67720f2bc 100644
--- a/components/Search.tsx
+++ b/components/Search.tsx
@@ -19,10 +19,9 @@ type Props = {
query: Query;
total: number;
style?: StyleProp;
- isHomePage?: boolean;
};
-export default function Search({ query, total, style, isHomePage = false }: Props) {
+export default function Search({ query, total, style }: Props) {
const { search, order, direction, offset, owner, ...filterParams } = query;
const [isInputFocused, setInputFocused] = useState(false);
const [isFilterVisible, setFilterVisible] = useState(Object.keys(filterParams).length > 0);
@@ -40,13 +39,6 @@ export default function Search({ query, total, style, isHomePage = false }: Prop
}
}, [search, isInputFocused]);
- const keyDownListener = useEffectEvent((event: KeyboardEvent) => {
- if (event.key === 'k' && (isApple ? event.metaKey : event.ctrlKey)) {
- event.preventDefault();
- inputRef.current?.focus();
- }
- });
-
useEffect(() => {
if (isApple !== null) {
document.addEventListener('keydown', keyDownListener, false);
@@ -54,6 +46,13 @@ export default function Search({ query, total, style, isHomePage = false }: Prop
}
}, [isApple]);
+ const keyDownListener = useEffectEvent((event: KeyboardEvent) => {
+ if (event.key === 'k' && (isApple ? event.metaKey : event.ctrlKey)) {
+ event.preventDefault();
+ inputRef.current?.focus();
+ }
+ });
+
const typingCallback = useDebouncedCallback((text: string) => {
void replace(urlWithQuery('/packages', { ...query, search: text, offset: null }));
}, 200);
@@ -68,7 +67,7 @@ export default function Search({ query, total, style, isHomePage = false }: Prop
-
+
{
if ('key' in event) {
- if (isHomePage && event.key === 'Enter') {
- event.preventDefault();
- void replace(
- urlWithQuery('/packages', {
- ...query,
- // @ts-expect-error using native input value
- search: inputRef.current.value,
- offset: undefined,
- })
- );
- }
if (
event.key === 'k' &&
(('metaKey' in event && event.metaKey) || ('ctrlKey' in event && event.ctrlKey))
@@ -112,28 +100,25 @@ export default function Search({ query, total, style, isHomePage = false }: Prop
}}
onFocus={() => setInputFocused(true)}
onBlur={() => setInputFocused(false)}
- onChangeText={isHomePage ? undefined : typingCallback}
+ onChangeText={typingCallback}
placeholder="Search libraries..."
- style={tw`h-12.5 font-sans flex flex-1 rounded-md border-2 border-palette-gray5 bg-palette-gray6 p-4 pl-11 text-xl text-white -outline-offset-2 dark:border-default dark:bg-dark`}
+ style={[
+ tw`h-12.5 font-sans pr-30 flex flex-1 rounded-md border-2 border-palette-gray5 bg-palette-gray6 p-4 text-xl text-white -outline-offset-2 dark:border-default dark:bg-dark`,
+ !isSmallScreen && tw`pl-11`,
+ ]}
defaultValue={search}
placeholderTextColor={tw`text-palette-gray4`.color as ColorValue}
/>
{!isSmallScreen && (
{isInputFocused ? (
- isHomePage ? (
-
- ) : (
- 0 ? 'clear' : 'blur'}` },
- ]}
- />
- )
+ 0 ? 'clear' : 'blur'}` },
+ ]}
+ />
) : (
)}
- {!isHomePage && (
-
- {total ? (
-
-
{total}
{' '}
- {total === 1 ? 'entry' : 'entries'}
-
- ) : (
-
- )}
-
- setFilterVisible(!isFilterVisible)}
- onClearAllPress={handleClearAllPress}
- isFilterVisible={isFilterVisible}
- />
-
-
+
+ {total ? (
+
+
{total}
{' '}
+ {total === 1 ? 'entry' : 'entries'}
+
+ ) : (
+
+ )}
+
+ setFilterVisible(!isFilterVisible)}
+ onClearAllPress={handleClearAllPress}
+ isFilterVisible={isFilterVisible}
+ />
+
- )}
+
{isFilterVisible && }
diff --git a/components/Tag.tsx b/components/Tag.tsx
index 799e81bfc..f84f682e2 100644
--- a/components/Tag.tsx
+++ b/components/Tag.tsx
@@ -9,12 +9,14 @@ import { Check } from './Icons';
type Props = {
label: string;
tagStyle: StyleProp;
+ small?: boolean;
icon?: ReactElement | null;
};
export function Tag({
label,
tagStyle,
+ small,
icon = ,
}: Props) {
return (
@@ -22,10 +24,11 @@ export function Tag({
key={label}
style={[
tw`min-h-6 select-none flex-row items-center gap-[5px] rounded border px-2 py-1`,
+ small && tw`min-h-5 px-1.5 py-0.5`,
tagStyle,
]}>
- {icon}
-
+ {!small && icon}
+
);
}
diff --git a/scenes/HomeScene.tsx b/scenes/HomeScene.tsx
index dc537a7c7..5e506a7cd 100644
--- a/scenes/HomeScene.tsx
+++ b/scenes/HomeScene.tsx
@@ -20,7 +20,7 @@ import {
} from '~/components/Icons';
import Navigation from '~/components/Navigation';
import PageMeta from '~/components/PageMeta';
-import Search from '~/components/Search';
+import QuickSearch from '~/components/QuickSearch';
import { type HomePageProps } from '~/types/pages';
import tw from '~/util/tailwind';
import urlWithQuery from '~/util/urlWithQuery';
@@ -41,11 +41,9 @@ export default function HomeScene({
-
diff --git a/styles/styles.css b/styles/styles.css
index 328dc985a..1882d3281 100644
--- a/styles/styles.css
+++ b/styles/styles.css
@@ -75,6 +75,8 @@
--table-header-background: var(--gray-2);
--code-block-background: var(--gray-2);
--inline-code-background: var(--gray-2);
+
+ --quick-search-bg: #ffffff;
}
:root.dark {
@@ -113,6 +115,8 @@
--table-header-background: var(--dark);
--code-block-background: var(--very-dark);
--inline-code-background: var(--gray-6);
+
+ --quick-search-bg: #111114ef;
}
*:focus-visible {
@@ -352,6 +356,23 @@ select {
}
}
+/* QUICK SEARCH */
+
+#quick-search-results {
+ background: var(--quick-search-bg);
+ backdrop-filter: blur(6px);
+}
+
+#spinner {
+ animation: spin 4s linear infinite;
+}
+
+@keyframes spin {
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
/* PACKAGE STICKY CONTAINER */
#metadataContainer {
From 4885ba60c0dd2249aa4dfbada77acee8e1949c00 Mon Sep 17 00:00:00 2001
From: Bartosz Kaszubowski
Date: Thu, 26 Mar 2026 16:34:08 +0100
Subject: [PATCH 2/5] improve quick search result row, mark unmaintained, other
tweaks
---
components/Library/LibraryDescription.tsx | 10 +++++---
components/Library/NewArchitectureTag.tsx | 2 +-
components/QuickSearch.tsx | 4 ++--
components/QuickSearchResult.tsx | 28 ++++++++++++++++++-----
4 files changed, 32 insertions(+), 12 deletions(-)
diff --git a/components/Library/LibraryDescription.tsx b/components/Library/LibraryDescription.tsx
index 5b5cbf6d7..4f951e2f1 100644
--- a/components/Library/LibraryDescription.tsx
+++ b/components/Library/LibraryDescription.tsx
@@ -1,5 +1,7 @@
import * as emoji from 'node-emoji';
import { Linkify } from 'react-easy-linkify';
+import { type StyleProp } from 'react-native';
+import { type Style } from 'twrnc';
import { A, Caption, Headline } from '~/common/styleguide';
import { type LibraryType } from '~/types';
@@ -8,11 +10,12 @@ import tw from '~/util/tailwind';
type Props = {
github: LibraryType['github'];
maxLines?: number;
+ style?: StyleProp