refactor(gifPicker): replace back arrow with clear X inside search input

Swap the mobile-web back arrow for an inline clear (X) button that sits
on the right side of the search input and shows whenever the search has
text. Clicking it clears the input and resets the active category to
trending, returning the user to the default view. Also drops the
viewKey-driven FlatList remount, which was the source of the
"dialog-reopening" animation when crossing the recents/network boundary
— React reconciliation handles the items swap cleanly on its own.

Verified in browser:
- Typing is responsive on every keystroke; no character-eating
- Clearing via the new X returns to pills + trending
- Switching recents <-> trending no longer animates
- Search-then-clear-then-recents shows only recents (no leftovers)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
vineyardbovines
2026-04-22 11:13:49 -04:00
parent d561170d83
commit 6250f4bb8c
3 changed files with 28 additions and 41 deletions
+9 -12
View File
@@ -100,24 +100,21 @@ function GifPickerBody({
const items = isRecentsActive ? getRecents() : networkItems const items = isRecentsActive ? getRecents() : networkItems
const hasData = items.length > 0 const hasData = items.length > 0
// Remount the grid only when switching between the network view and the
// local recents list, so FlatList doesn't carry stale virtualized cells
// across that boundary. Within the network view we rely on normal React
// reconciliation as `items` changes — remounting on every search keystroke
// would blur the sticky-header search input.
const viewKey = isRecentsActive ? 'recents' : 'network'
const onEndReached = () => { const onEndReached = () => {
if (isRecentsActive) return if (isRecentsActive) return
if (isFetchingNextPage || !hasNextPage || error) return if (isFetchingNextPage || !hasNextPage || error) return
void fetchNextPage() void fetchNextPage()
} }
const onClearSearch = () => {
textInputRef.current?.clear()
setRawSearch('')
setActiveCategory('trending')
}
const onGoBack = () => { const onGoBack = () => {
if (isSearching || activeCategory !== 'trending') { if (isSearching || activeCategory !== 'trending') {
textInputRef.current?.clear() onClearSearch()
setRawSearch('')
setActiveCategory('trending')
} else { } else {
control.close() control.close()
} }
@@ -145,7 +142,8 @@ function GifPickerBody({
<GifPickerHeader <GifPickerHeader
inputRef={textInputRef} inputRef={textInputRef}
onChangeText={onChangeSearch} onChangeText={onChangeSearch}
onGoBack={onGoBack} onClear={onClearSearch}
canClear={rawSearch.length > 0}
onEscape={() => control.close()} onEscape={() => control.close()}
/> />
{showPills && ( {showPills && (
@@ -179,7 +177,6 @@ function GifPickerBody({
hasData={hasData} hasData={hasData}
isFetchingNextPage={!isRecentsActive && isFetchingNextPage} isFetchingNextPage={!isRecentsActive && isFetchingNextPage}
error={isRecentsActive ? null : error} error={isRecentsActive ? null : error}
viewKey={viewKey}
fetchNextPage={fetchNextPage} fetchNextPage={fetchNextPage}
onEndReached={onEndReached} onEndReached={onEndReached}
onSelectGif={handleSelectGif} onSelectGif={handleSelectGif}
@@ -15,13 +15,6 @@ type Props = {
hasData: boolean hasData: boolean
isFetchingNextPage: boolean isFetchingNextPage: boolean
error: unknown error: unknown
/**
* Identifies the current data source at a coarse grain ("recents" vs
* "network"). Used to remount the underlying FlatList when crossing that
* boundary so virtualized cells from the previous view can't bleed into the
* next one.
*/
viewKey: string
fetchNextPage: () => Promise<unknown> fetchNextPage: () => Promise<unknown>
onEndReached: () => void onEndReached: () => void
onSelectGif: (gif: Gif) => void onSelectGif: (gif: Gif) => void
@@ -35,7 +28,6 @@ export const GifPickerGrid = forwardRef<ListMethods, Props>(
hasData, hasData,
isFetchingNextPage, isFetchingNextPage,
error, error,
viewKey,
fetchNextPage, fetchNextPage,
onEndReached, onEndReached,
onSelectGif, onSelectGif,
@@ -59,7 +51,7 @@ export const GifPickerGrid = forwardRef<ListMethods, Props>(
return ( return (
<Dialog.InnerFlatList <Dialog.InnerFlatList
ref={ref} ref={ref}
key={`${numColumns}-${viewKey}`} key={String(numColumns)}
data={data} data={data}
renderItem={({item}: {item: Gif[][]}) => ( renderItem={({item}: {item: Gif[][]}) => (
<View style={[a.flex_row, a.gap_sm]}> <View style={[a.flex_row, a.gap_sm]}>
@@ -2,27 +2,27 @@ import {type Ref} from 'react'
import {type TextInput, View} from 'react-native' import {type TextInput, View} from 'react-native'
import {useLingui} from '@lingui/react/macro' import {useLingui} from '@lingui/react/macro'
import {atoms as a, native, useBreakpoints, useTheme, web} from '#/alf' import {atoms as a, native, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button' import {Button, ButtonIcon} from '#/components/Button'
import * as TextField from '#/components/forms/TextField' import * as TextField from '#/components/forms/TextField'
import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass' import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass'
import {IS_WEB} from '#/env' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
export function GifPickerHeader({ export function GifPickerHeader({
inputRef, inputRef,
onChangeText, onChangeText,
onGoBack, onClear,
onEscape, onEscape,
canClear,
}: { }: {
inputRef: Ref<TextInput> inputRef: Ref<TextInput>
onChangeText: (text: string) => void onChangeText: (text: string) => void
onGoBack: () => void onClear: () => void
onEscape: () => void onEscape: () => void
canClear: boolean
}) { }) {
const {t: l} = useLingui() const {t: l} = useLingui()
const t = useTheme() const t = useTheme()
const {gtMobile} = useBreakpoints()
return ( return (
<View <View
@@ -32,28 +32,15 @@ export function GifPickerHeader({
a.pb_md, a.pb_md,
a.flex_row, a.flex_row,
a.align_center, a.align_center,
!gtMobile && web(a.gap_md),
t.atoms.bg, t.atoms.bg,
]}> ]}>
{!gtMobile && IS_WEB && ( <TextField.Root style={a.flex_1}>
<Button
size="small"
color="secondary"
shape="round"
onPress={onGoBack}
label={l`Go back`}>
<ButtonIcon icon={Arrow} size="md" />
</Button>
)}
<TextField.Root style={[!gtMobile && IS_WEB && a.flex_1]}>
<TextField.Icon icon={Search} /> <TextField.Icon icon={Search} />
<TextField.Input <TextField.Input
label={l`Search GIFs`} label={l`Search GIFs`}
placeholder={l`Search GIFs`} placeholder={l`Search GIFs`}
onChangeText={onChangeText} onChangeText={onChangeText}
returnKeyType="search" returnKeyType="search"
clearButtonMode="while-editing"
inputRef={inputRef} inputRef={inputRef}
maxLength={50} maxLength={50}
onKeyPress={({nativeEvent}) => { onKeyPress={({nativeEvent}) => {
@@ -62,6 +49,17 @@ export function GifPickerHeader({
} }
}} }}
/> />
{canClear && (
<Button
size="tiny"
color="secondary"
shape="round"
style={a.z_30}
onPress={onClear}
label={l`Clear search`}>
<ButtonIcon icon={X} size="sm" />
</Button>
)}
</TextField.Root> </TextField.Root>
</View> </View>
) )