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 -
- - - )} + {!(small && isSmallScreen) && + (library.expoGo ?? library.fireos ?? library.vegaos ?? library.horizon) && ( + + +
+ }> + Additional information +
+ + + )} ); } 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