diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/alf/themes.ts | 3 | ||||
-rw-r--r-- | src/alf/types.ts | 1 | ||||
-rw-r--r-- | src/components/forms/TextField.tsx | 23 | ||||
-rw-r--r-- | src/screens/Search/__tests__/utils.test.ts | 43 | ||||
-rw-r--r-- | src/screens/Search/utils.ts | 43 | ||||
-rw-r--r-- | src/view/screens/Search/Search.tsx | 461 | ||||
-rw-r--r-- | src/view/screens/Storybook/Forms.tsx | 2 |
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={[ { |