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:
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user