about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/alf/themes.ts3
-rw-r--r--src/alf/types.ts1
-rw-r--r--src/components/forms/TextField.tsx23
-rw-r--r--src/screens/Search/__tests__/utils.test.ts43
-rw-r--r--src/screens/Search/utils.ts43
-rw-r--r--src/view/screens/Search/Search.tsx461
-rw-r--r--src/view/screens/Storybook/Forms.tsx2
7 files changed, 427 insertions, 149 deletions
diff --git a/src/alf/themes.ts b/src/alf/themes.ts
index 9f7ec5c67..0cfe09aad 100644
--- a/src/alf/themes.ts
+++ b/src/alf/themes.ts
@@ -305,6 +305,7 @@ export function createThemes({
   } as const
 
   const light: Theme = {
+    scheme: 'light',
     name: 'light',
     palette: lightPalette,
     atoms: {
@@ -390,6 +391,7 @@ export function createThemes({
   }
 
   const dark: Theme = {
+    scheme: 'dark',
     name: 'dark',
     palette: darkPalette,
     atoms: {
@@ -479,6 +481,7 @@ export function createThemes({
 
   const dim: Theme = {
     ...dark,
+    scheme: 'dark',
     name: 'dim',
     palette: dimPalette,
     atoms: {
diff --git a/src/alf/types.ts b/src/alf/types.ts
index 41822b8dd..08ec59392 100644
--- a/src/alf/types.ts
+++ b/src/alf/types.ts
@@ -156,6 +156,7 @@ export type ThemedAtoms = {
   }
 }
 export type Theme = {
+  scheme: 'light' | 'dark' // for library support
   name: ThemeName
   palette: Palette
   atoms: ThemedAtoms
diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx
index 94ee261e3..21928d3df 100644
--- a/src/components/forms/TextField.tsx
+++ b/src/components/forms/TextField.tsx
@@ -135,6 +135,8 @@ export function createInput(Component: typeof TextInput) {
     placeholder,
     value,
     onChangeText,
+    onFocus,
+    onBlur,
     isInvalid,
     inputRef,
     style,
@@ -173,8 +175,14 @@ export function createInput(Component: typeof TextInput) {
           ref={refs}
           value={value}
           onChangeText={onChangeText}
-          onFocus={ctx.onFocus}
-          onBlur={ctx.onBlur}
+          onFocus={e => {
+            ctx.onFocus()
+            onFocus?.(e)
+          }}
+          onBlur={e => {
+            ctx.onBlur()
+            onBlur?.(e)
+          }}
           placeholder={placeholder || label}
           placeholderTextColor={t.palette.contrast_500}
           keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
@@ -188,8 +196,8 @@ export function createInput(Component: typeof TextInput) {
             a.px_xs,
             {
               // paddingVertical doesn't work w/multiline - esb
-              paddingTop: 14,
-              paddingBottom: 14,
+              paddingTop: 12,
+              paddingBottom: 13,
               lineHeight: a.text_md.fontSize * 1.1875,
               textAlignVertical: rest.multiline ? 'top' : undefined,
               minHeight: rest.multiline ? 80 : undefined,
@@ -197,13 +205,14 @@ export function createInput(Component: typeof TextInput) {
             },
             // fix for autofill styles covering border
             web({
-              paddingTop: 12,
-              paddingBottom: 12,
+              paddingTop: 10,
+              paddingBottom: 11,
               marginTop: 2,
               marginBottom: 2,
             }),
             android({
-              paddingBottom: 16,
+              paddingTop: 8,
+              paddingBottom: 8,
             }),
             style,
           ]}
diff --git a/src/screens/Search/__tests__/utils.test.ts b/src/screens/Search/__tests__/utils.test.ts
new file mode 100644
index 000000000..81610cc59
--- /dev/null
+++ b/src/screens/Search/__tests__/utils.test.ts
@@ -0,0 +1,43 @@
+import {describe, expect, it} from '@jest/globals'
+
+import {parseSearchQuery} from '#/screens/Search/utils'
+
+describe(`parseSearchQuery`, () => {
+  const tests = [
+    {
+      input: `bluesky`,
+      output: {query: `bluesky`, params: {}},
+    },
+    {
+      input: `bluesky from:esb.lol`,
+      output: {query: `bluesky`, params: {from: `esb.lol`}},
+    },
+    {
+      input: `bluesky "from:esb.lol"`,
+      output: {query: `bluesky "from:esb.lol"`, params: {}},
+    },
+    {
+      input: `bluesky mentions:@esb.lol`,
+      output: {query: `bluesky`, params: {mentions: `@esb.lol`}},
+    },
+    {
+      input: `bluesky since:2021-01-01:00:00:00`,
+      output: {query: `bluesky`, params: {since: `2021-01-01:00:00:00`}},
+    },
+    {
+      input: `bluesky lang:"en"`,
+      output: {query: `bluesky`, params: {lang: `en`}},
+    },
+    {
+      input: `bluesky "literal" lang:en "from:invalid"`,
+      output: {query: `bluesky "literal" "from:invalid"`, params: {lang: `en`}},
+    },
+  ]
+
+  it.each(tests)(
+    `$input -> $output.query $output.params`,
+    ({input, output}) => {
+      expect(parseSearchQuery(input)).toEqual(output)
+    },
+  )
+})
diff --git a/src/screens/Search/utils.ts b/src/screens/Search/utils.ts
new file mode 100644
index 000000000..dcf92c092
--- /dev/null
+++ b/src/screens/Search/utils.ts
@@ -0,0 +1,43 @@
+export type Params = Record<string, string>
+
+export function parseSearchQuery(rawQuery: string) {
+  let base = rawQuery
+  const rawLiterals = rawQuery.match(/[^:\w\d]".+?"/gi) || []
+
+  // remove literals from base
+  for (const literal of rawLiterals) {
+    base = base.replace(literal.trim(), '')
+  }
+
+  // find remaining params in base
+  const rawParams = base.match(/[a-z]+:[a-z-\.@\d:"]+/gi) || []
+
+  for (const param of rawParams) {
+    base = base.replace(param, '')
+  }
+
+  base = base.trim()
+
+  const params = rawParams.reduce((params, param) => {
+    const [name, ...value] = param.split(/:/)
+    params[name] = value.join(':').replace(/"/g, '') // dates can contain additional colons
+    return params
+  }, {} as Params)
+  const literals = rawLiterals.map(l => String(l).trim())
+
+  return {
+    query: [base, literals.join(' ')].filter(Boolean).join(' '),
+    params,
+  }
+}
+
+export function makeSearchQuery(query: string, params: Params) {
+  return [
+    query,
+    Object.entries(params)
+      .map(([name, value]) => `${name}:${value}`)
+      .join(' '),
+  ]
+    .filter(Boolean)
+    .join(' ')
+}
diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx
index 07d762c0f..cfd77f7ef 100644
--- a/src/view/screens/Search/Search.tsx
+++ b/src/view/screens/Search/Search.tsx
@@ -11,6 +11,7 @@ import {
   View,
 } from 'react-native'
 import {ScrollView as RNGHScrollView} from 'react-native-gesture-handler'
+import RNPickerSelect from 'react-native-picker-select'
 import {AppBskyActorDefs, AppBskyFeedDefs, moderateProfile} from '@atproto/api'
 import {
   FontAwesomeIcon,
@@ -21,6 +22,7 @@ import {useLingui} from '@lingui/react'
 import AsyncStorage from '@react-native-async-storage/async-storage'
 import {useFocusEffect, useNavigation} from '@react-navigation/native'
 
+import {LANGUAGES} from '#/lib/../locale/languages'
 import {useAnalytics} from '#/lib/analytics/analytics'
 import {createHitslop} from '#/lib/constants'
 import {HITSLOP_10} from '#/lib/constants'
@@ -35,10 +37,10 @@ import {
   SearchTabNavigatorParams,
 } from '#/lib/routes/types'
 import {augmentSearchQuery} from '#/lib/strings/helpers'
-import {useTheme} from '#/lib/ThemeContext'
 import {logger} from '#/logger'
 import {isNative, isWeb} from '#/platform/detection'
 import {listenSoftReset} from '#/state/events'
+import {useLanguagePrefs} from '#/state/preferences/languages'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
 import {useActorSearch} from '#/state/queries/actor-search'
@@ -57,9 +59,16 @@ import {Text} from '#/view/com/util/text/Text'
 import {CenteredView, ScrollView} from '#/view/com/util/Views'
 import {Explore} from '#/view/screens/Search/Explore'
 import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search'
-import {atoms as a, useTheme as useThemeNew} from '#/alf'
+import {makeSearchQuery, parseSearchQuery} from '#/screens/Search/utils'
+import {atoms as a, useBreakpoints, useTheme as useThemeNew, web} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 import * as FeedCard from '#/components/FeedCard'
+import * as TextField from '#/components/forms/TextField'
+import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron'
+import {MagnifyingGlass2_Stroke2_Corner0_Rounded as MagnifyingGlass} from '#/components/icons/MagnifyingGlass2'
 import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
+import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2'
+import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
 
 function Loader() {
   const pal = usePalette('default')
@@ -251,7 +260,7 @@ let SearchScreenUserResults = ({
   const {_} = useLingui()
 
   const {data: results, isFetched} = useActorSearch({
-    query: query,
+    query,
     enabled: active,
   })
 
@@ -324,7 +333,137 @@ let SearchScreenFeedsResults = ({
 }
 SearchScreenFeedsResults = React.memo(SearchScreenFeedsResults)
 
-let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
+function SearchLanguageDropdown({
+  value,
+  onChange,
+}: {
+  value: string
+  onChange(value: string): void
+}) {
+  const t = useThemeNew()
+  const {contentLanguages} = useLanguagePrefs()
+
+  const items = React.useMemo(() => {
+    return LANGUAGES.filter(l => Boolean(l.code2))
+      .map(l => ({
+        label: l.name,
+        inputLabel: l.name,
+        value: l.code2,
+        key: l.code2 + l.code3,
+      }))
+      .sort(a => (contentLanguages.includes(a.value) ? -1 : 1))
+  }, [contentLanguages])
+
+  const style = {
+    backgroundColor: t.atoms.bg_contrast_25.backgroundColor,
+    color: t.atoms.text.color,
+    fontSize: a.text_xs.fontSize,
+    fontFamily: 'inherit',
+    fontWeight: a.font_bold.fontWeight,
+    paddingHorizontal: 14,
+    paddingRight: 32,
+    paddingVertical: 8,
+    borderRadius: a.rounded_full.borderRadius,
+    borderWidth: a.border.borderWidth,
+    borderColor: t.atoms.border_contrast_low.borderColor,
+  }
+
+  return (
+    <RNPickerSelect
+      value={value}
+      onValueChange={onChange}
+      items={items}
+      Icon={() => (
+        <ChevronDown fill={t.atoms.text_contrast_low.color} size="sm" />
+      )}
+      useNativeAndroidPickerStyle={false}
+      style={{
+        iconContainer: {
+          pointerEvents: 'none',
+          right: a.px_sm.paddingRight,
+          top: 0,
+          bottom: 0,
+          display: 'flex',
+          justifyContent: 'center',
+        },
+        inputAndroid: {
+          ...style,
+          paddingVertical: 2,
+        },
+        inputIOS: {
+          ...style,
+        },
+        inputWeb: web({
+          ...style,
+          cursor: 'pointer',
+          // @ts-ignore web only
+          '-moz-appearance': 'none',
+          '-webkit-appearance': 'none',
+          appearance: 'none',
+          outline: 0,
+          borderWidth: 0,
+          overflow: 'hidden',
+          whiteSpace: 'nowrap',
+          textOverflow: 'ellipsis',
+        }),
+      }}
+    />
+  )
+}
+
+function useQueryManager({initialQuery}: {initialQuery: string}) {
+  const {contentLanguages} = useLanguagePrefs()
+  const {query, params: initialParams} = React.useMemo(() => {
+    return parseSearchQuery(initialQuery || '')
+  }, [initialQuery])
+  const prevInitialQuery = React.useRef(initialQuery)
+  const [lang, setLang] = React.useState(
+    initialParams.lang || contentLanguages[0],
+  )
+
+  if (initialQuery !== prevInitialQuery.current) {
+    // handle new queryParam change (from manual search entry)
+    prevInitialQuery.current = initialQuery
+    setLang(initialParams.lang || contentLanguages[0])
+  }
+
+  const params = React.useMemo(
+    () => ({
+      // default stuff
+      ...initialParams,
+      // managed stuff
+      lang,
+    }),
+    [lang, initialParams],
+  )
+  const handlers = React.useMemo(
+    () => ({
+      setLang,
+    }),
+    [setLang],
+  )
+
+  return React.useMemo(() => {
+    return {
+      query,
+      queryWithParams: makeSearchQuery(query, params),
+      params: {
+        ...params,
+        ...handlers,
+      },
+    }
+  }, [query, params, handlers])
+}
+
+let SearchScreenInner = ({
+  query,
+  queryWithParams,
+  headerHeight,
+}: {
+  query: string
+  queryWithParams: string
+  headerHeight: number
+}): React.ReactNode => {
   const pal = usePalette('default')
   const setMinimalShellMode = useSetMinimalShellMode()
   const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
@@ -349,7 +488,7 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
         title: _(msg`Top`),
         component: (
           <SearchScreenPostResults
-            query={query}
+            query={queryWithParams}
             sort="top"
             active={activeTab === 0}
           />
@@ -359,7 +498,7 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
         title: _(msg`Latest`),
         component: (
           <SearchScreenPostResults
-            query={query}
+            query={queryWithParams}
             sort="latest"
             active={activeTab === 1}
           />
@@ -378,7 +517,7 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
         ),
       },
     ]
-  }, [_, query, activeTab])
+  }, [_, query, queryWithParams, activeTab])
 
   return query ? (
     <Pager
@@ -386,7 +525,15 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
       renderTabBar={props => (
         <CenteredView
           sideBorders
-          style={[pal.border, pal.view, styles.tabBarContainer]}>
+          style={[
+            pal.border,
+            pal.view,
+            web({
+              position: isWeb ? 'sticky' : '',
+              zIndex: 1,
+            }),
+            {top: isWeb ? headerHeight : undefined},
+          ]}>
           <TabBar items={sections.map(section => section.title)} {...props} />
         </CenteredView>
       )}
@@ -448,14 +595,14 @@ SearchScreenInner = React.memo(SearchScreenInner)
 export function SearchScreen(
   props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>,
 ) {
+  const t = useThemeNew()
+  const {gtMobile} = useBreakpoints()
   const navigation = useNavigation<NavigationProp>()
   const textInput = React.useRef<TextInput>(null)
   const {_} = useLingui()
-  const pal = usePalette('default')
   const {track} = useAnalytics()
   const setDrawerOpen = useSetDrawerOpen()
   const setMinimalShellMode = useSetMinimalShellMode()
-  const {isTabletOrDesktop, isTabletOrMobile} = useWebMediaQueries()
 
   // Query terms
   const queryParam = props.route?.params?.q ?? ''
@@ -469,6 +616,17 @@ export function SearchScreen(
     AppBskyActorDefs.ProfileViewBasic[]
   >([])
 
+  const {params, query, queryWithParams} = useQueryManager({
+    initialQuery: queryParam,
+  })
+  const showFiltersButton = Boolean(query && !showAutocomplete)
+  const [showFilters, setShowFilters] = React.useState(false)
+  /*
+   * Arbitrary sizing, so guess and check, used for sticky header alignment and
+   * sizing.
+   */
+  const headerHeight = 56 + (showFilters ? 40 : 0)
+
   useFocusEffect(
     useNonReactiveCallback(() => {
       if (isWeb) {
@@ -507,13 +665,6 @@ export function SearchScreen(
     textInput.current?.focus()
   }, [])
 
-  const onPressCancelSearch = React.useCallback(() => {
-    scrollToTopWeb()
-    textInput.current?.blur()
-    setShowAutocomplete(false)
-    setSearchText(queryParam)
-  }, [queryParam])
-
   const onChangeText = React.useCallback(async (text: string) => {
     scrollToTopWeb()
     setSearchText(text)
@@ -586,6 +737,13 @@ export function SearchScreen(
     [updateSearchHistory, navigation],
   )
 
+  const onPressCancelSearch = React.useCallback(() => {
+    scrollToTopWeb()
+    textInput.current?.blur()
+    setShowAutocomplete(false)
+    setSearchText(queryParam)
+  }, [setShowAutocomplete, setSearchText, queryParam])
+
   const onSubmit = React.useCallback(() => {
     navigateToItem(searchText)
   }, [navigateToItem, searchText])
@@ -624,6 +782,7 @@ export function SearchScreen(
       setSearchText('')
       navigation.setParams({q: ''})
     }
+    setShowFilters(false)
   }, [navigation])
 
   useFocusEffect(
@@ -663,50 +822,107 @@ export function SearchScreen(
     [selectedProfiles],
   )
 
+  const onSearchInputFocus = React.useCallback(() => {
+    if (isWeb) {
+      // Prevent a jump on iPad by ensuring that
+      // the initial focused render has no result list.
+      requestAnimationFrame(() => {
+        setShowAutocomplete(true)
+      })
+    } else {
+      setShowAutocomplete(true)
+    }
+    setShowFilters(false)
+  }, [setShowAutocomplete])
+
   return (
     <View style={isWeb ? null : {flex: 1}}>
       <CenteredView
         style={[
-          styles.header,
-          pal.border,
-          pal.view,
-          isTabletOrDesktop && {paddingTop: 10},
+          a.p_md,
+          a.pb_0,
+          a.gap_sm,
+          t.atoms.bg,
+          web({
+            height: headerHeight,
+            position: 'sticky',
+            top: 0,
+            zIndex: 1,
+          }),
         ]}
-        sideBorders={isTabletOrDesktop}>
-        {isTabletOrMobile && (
-          <Pressable
-            testID="viewHeaderBackOrMenuBtn"
-            onPress={onPressMenu}
-            hitSlop={HITSLOP_10}
-            style={styles.headerMenuBtn}
-            accessibilityRole="button"
-            accessibilityLabel={_(msg`Menu`)}
-            accessibilityHint={_(msg`Access navigation links and settings`)}>
-            <Menu size="lg" fill={pal.colors.textLight} />
-          </Pressable>
-        )}
-        <SearchInputBox
-          textInput={textInput}
-          searchText={searchText}
-          showAutocomplete={showAutocomplete}
-          setShowAutocomplete={setShowAutocomplete}
-          onChangeText={onChangeText}
-          onSubmit={onSubmit}
-          onPressClearQuery={onPressClearQuery}
-        />
-        {showAutocomplete && (
-          <View style={[styles.headerCancelBtn]}>
-            <Pressable
+        sideBorders={gtMobile}>
+        <View style={[a.flex_row, a.gap_sm]}>
+          {!gtMobile && (
+            <Button
+              testID="viewHeaderBackOrMenuBtn"
+              onPress={onPressMenu}
+              hitSlop={HITSLOP_10}
+              label={_(msg`Menu`)}
+              accessibilityHint={_(msg`Access navigation links and settings`)}
+              size="large"
+              variant="solid"
+              color="secondary"
+              shape="square">
+              <ButtonIcon icon={Menu} size="lg" />
+            </Button>
+          )}
+          <SearchInputBox
+            textInput={textInput}
+            searchText={searchText}
+            showAutocomplete={showAutocomplete}
+            onFocus={onSearchInputFocus}
+            onChangeText={onChangeText}
+            onSubmit={onSubmit}
+            onPressClearQuery={onPressClearQuery}
+          />
+          {showFiltersButton && (
+            <Button
+              onPress={() => setShowFilters(!showFilters)}
+              hitSlop={HITSLOP_10}
+              label={_(msg`Show advanced filters`)}
+              size="large"
+              variant="solid"
+              color="secondary"
+              shape="square">
+              <Gear
+                size="md"
+                fill={
+                  showFilters
+                    ? t.palette.primary_500
+                    : t.atoms.text_contrast_low.color
+                }
+              />
+            </Button>
+          )}
+          {showAutocomplete && (
+            <Button
+              label={_(msg`Cancel search`)}
+              size="large"
+              variant="ghost"
+              color="secondary"
+              style={[a.px_sm]}
               onPress={onPressCancelSearch}
-              accessibilityRole="button"
               hitSlop={HITSLOP_10}>
-              <Text style={pal.text}>
+              <ButtonText>
                 <Trans>Cancel</Trans>
-              </Text>
-            </Pressable>
+              </ButtonText>
+            </Button>
+          )}
+        </View>
+
+        {showFilters && (
+          <View
+            style={[a.flex_row, a.align_center, a.justify_between, a.gap_sm]}>
+            <View style={[{width: 140}]}>
+              <SearchLanguageDropdown
+                value={params.lang}
+                onChange={params.setLang}
+              />
+            </View>
           </View>
         )}
       </CenteredView>
+
       <View
         style={{
           display: showAutocomplete ? 'flex' : 'none',
@@ -737,7 +953,11 @@ export function SearchScreen(
           display: showAutocomplete ? 'none' : 'flex',
           flex: 1,
         }}>
-        <SearchScreenInner query={queryParam} />
+        <SearchScreenInner
+          query={query}
+          queryWithParams={queryWithParams}
+          headerHeight={headerHeight}
+        />
       </View>
     </View>
   )
@@ -747,7 +967,7 @@ let SearchInputBox = ({
   textInput,
   searchText,
   showAutocomplete,
-  setShowAutocomplete,
+  onFocus,
   onChangeText,
   onSubmit,
   onPressClearQuery,
@@ -755,83 +975,62 @@ let SearchInputBox = ({
   textInput: React.RefObject<TextInput>
   searchText: string
   showAutocomplete: boolean
-  setShowAutocomplete: (show: boolean) => void
+  onFocus: () => void
   onChangeText: (text: string) => void
   onSubmit: () => void
   onPressClearQuery: () => void
 }): React.ReactNode => {
-  const pal = usePalette('default')
   const {_} = useLingui()
-  const theme = useTheme()
+  const t = useThemeNew()
+
   return (
-    <Pressable
-      // This only exists only for extra hitslop so don't expose it to the a11y tree.
-      accessible={false}
-      focusable={false}
-      // @ts-ignore web-only
-      tabIndex={-1}
-      style={[
-        {backgroundColor: pal.colors.backgroundLight},
-        styles.headerSearchContainer,
-        // @ts-expect-error web only
-        isWeb && {
-          cursor: 'default',
-        },
-      ]}
-      onPress={() => {
-        textInput.current?.focus()
-      }}>
-      <MagnifyingGlassIcon
-        style={[pal.icon, styles.headerSearchIcon]}
-        size={20}
-      />
-      <TextInput
-        testID="searchTextInput"
-        ref={textInput}
-        placeholder={_(msg`Search`)}
-        placeholderTextColor={pal.colors.textLight}
-        returnKeyType="search"
-        value={searchText}
-        style={[pal.text, styles.headerSearchInput]}
-        keyboardAppearance={theme.colorScheme}
-        selectTextOnFocus={isNative}
-        onFocus={() => {
-          if (isWeb) {
-            // Prevent a jump on iPad by ensuring that
-            // the initial focused render has no result list.
-            requestAnimationFrame(() => {
-              setShowAutocomplete(true)
-            })
-          } else {
-            setShowAutocomplete(true)
-          }
-        }}
-        onChangeText={onChangeText}
-        onSubmitEditing={onSubmit}
-        autoFocus={false}
-        accessibilityRole="search"
-        accessibilityLabel={_(msg`Search`)}
-        accessibilityHint=""
-        autoCorrect={false}
-        autoComplete="off"
-        autoCapitalize="none"
-      />
+    <View style={[a.flex_1, a.relative]}>
+      <TextField.Root>
+        <TextField.Icon icon={MagnifyingGlass} />
+        <TextField.Input
+          inputRef={textInput}
+          label={_(msg`Search`)}
+          value={searchText}
+          placeholder={_(msg`Search`)}
+          returnKeyType="search"
+          onChangeText={onChangeText}
+          onSubmitEditing={onSubmit}
+          onFocus={onFocus}
+          keyboardAppearance={t.scheme}
+          selectTextOnFocus={isNative}
+          autoFocus={false}
+          accessibilityRole="search"
+          autoCorrect={false}
+          autoComplete="off"
+          autoCapitalize="none"
+        />
+      </TextField.Root>
+
       {showAutocomplete && searchText.length > 0 && (
-        <Pressable
-          testID="searchTextInputClearBtn"
-          onPress={onPressClearQuery}
-          accessibilityRole="button"
-          accessibilityLabel={_(msg`Clear search query`)}
-          accessibilityHint=""
-          hitSlop={HITSLOP_10}>
-          <FontAwesomeIcon
-            icon="xmark"
-            size={16}
-            style={pal.textLight as FontAwesomeIconStyle}
-          />
-        </Pressable>
+        <View
+          style={[
+            a.absolute,
+            a.z_10,
+            a.my_auto,
+            a.inset_0,
+            a.justify_center,
+            a.pr_sm,
+            {left: 'auto'},
+          ]}>
+          <Button
+            testID="searchTextInputClearBtn"
+            onPress={onPressClearQuery}
+            label={_(msg`Clear search query`)}
+            hitSlop={HITSLOP_10}
+            size="tiny"
+            shape="round"
+            variant="ghost"
+            color="secondary">
+            <ButtonIcon icon={X} size="sm" />
+          </Button>
+        </View>
       )}
-    </Pressable>
+    </View>
   )
 }
 SearchInputBox = React.memo(SearchInputBox)
@@ -1029,21 +1228,7 @@ function scrollToTopWeb() {
   }
 }
 
-const HEADER_HEIGHT = 46
-
 const styles = StyleSheet.create({
-  header: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    paddingHorizontal: 12,
-    paddingLeft: 13,
-    paddingVertical: 4,
-    height: HEADER_HEIGHT,
-    // @ts-ignore web only
-    position: isWeb ? 'sticky' : '',
-    top: 0,
-    zIndex: 1,
-  },
   headerMenuBtn: {
     width: 30,
     height: 30,
@@ -1075,12 +1260,6 @@ const styles = StyleSheet.create({
     zIndex: -1,
     elevation: -1, // For Android
   },
-  tabBarContainer: {
-    // @ts-ignore web only
-    position: isWeb ? 'sticky' : '',
-    top: isWeb ? HEADER_HEIGHT : 0,
-    zIndex: 1,
-  },
   searchHistoryContainer: {
     width: '100%',
     paddingHorizontal: 12,
diff --git a/src/view/screens/Storybook/Forms.tsx b/src/view/screens/Storybook/Forms.tsx
index fc414d31f..8ec118ae3 100644
--- a/src/view/screens/Storybook/Forms.tsx
+++ b/src/view/screens/Storybook/Forms.tsx
@@ -32,7 +32,7 @@ export function Forms() {
           label="Text field"
         />
 
-        <View style={[a.flex_row, a.gap_sm]}>
+        <View style={[a.flex_row, a.align_start, a.gap_sm]}>
           <View
             style={[
               {