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 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 = () => {
if (isRecentsActive) return
if (isFetchingNextPage || !hasNextPage || error) return
void fetchNextPage()
}
const onClearSearch = () => {
textInputRef.current?.clear()
setRawSearch('')
setActiveCategory('trending')
}
const onGoBack = () => {
if (isSearching || activeCategory !== 'trending') {
textInputRef.current?.clear()
setRawSearch('')
setActiveCategory('trending')
onClearSearch()
} else {
control.close()
}
@@ -145,7 +142,8 @@ function GifPickerBody({
<GifPickerHeader
inputRef={textInputRef}
onChangeText={onChangeSearch}
onGoBack={onGoBack}
onClear={onClearSearch}
canClear={rawSearch.length > 0}
onEscape={() => control.close()}
/>
{showPills && (
@@ -179,7 +177,6 @@ function GifPickerBody({
hasData={hasData}
isFetchingNextPage={!isRecentsActive && isFetchingNextPage}
error={isRecentsActive ? null : error}
viewKey={viewKey}
fetchNextPage={fetchNextPage}
onEndReached={onEndReached}
onSelectGif={handleSelectGif}
@@ -15,13 +15,6 @@ type Props = {
hasData: boolean
isFetchingNextPage: boolean
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>
onEndReached: () => void
onSelectGif: (gif: Gif) => void
@@ -35,7 +28,6 @@ export const GifPickerGrid = forwardRef<ListMethods, Props>(
hasData,
isFetchingNextPage,
error,
viewKey,
fetchNextPage,
onEndReached,
onSelectGif,
@@ -59,7 +51,7 @@ export const GifPickerGrid = forwardRef<ListMethods, Props>(
return (
<Dialog.InnerFlatList
ref={ref}
key={`${numColumns}-${viewKey}`}
key={String(numColumns)}
data={data}
renderItem={({item}: {item: Gif[][]}) => (
<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 {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 * 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 {IS_WEB} from '#/env'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
export function GifPickerHeader({
inputRef,
onChangeText,
onGoBack,
onClear,
onEscape,
canClear,
}: {
inputRef: Ref<TextInput>
onChangeText: (text: string) => void
onGoBack: () => void
onClear: () => void
onEscape: () => void
canClear: boolean
}) {
const {t: l} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
return (
<View
@@ -32,28 +32,15 @@ export function GifPickerHeader({
a.pb_md,
a.flex_row,
a.align_center,
!gtMobile && web(a.gap_md),
t.atoms.bg,
]}>
{!gtMobile && IS_WEB && (
<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.Root style={a.flex_1}>
<TextField.Icon icon={Search} />
<TextField.Input
label={l`Search GIFs`}
placeholder={l`Search GIFs`}
onChangeText={onChangeText}
returnKeyType="search"
clearButtonMode="while-editing"
inputRef={inputRef}
maxLength={50}
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>
</View>
)