From de4671806f3e449af3eea5609a29c266af3ced83 Mon Sep 17 00:00:00 2001 From: Mikita Kliushun Date: Mon, 21 Sep 2026 19:39:04 +0200 Subject: [PATCH] refactor: modernize Searchbar component --- docs/6.x/docs/guides/migration.md | 109 +- docs/component-docs.config.ts | 5 +- docs/src/data/screenshots.ts | 4 +- docs/src/data/themeColors.ts | 8 +- example/src/Examples/SearchbarExample.tsx | 84 +- src/components/Searchbar.tsx | 405 --- src/components/Searchbar/Searchbar.tsx | 612 +++++ src/components/Searchbar/SearchbarResults.tsx | 94 + src/components/Searchbar/index.tsx | 13 + src/components/Searchbar/tokens.ts | 43 + src/components/Searchbar/utils.ts | 51 + .../Appbar/__snapshots__/Appbar.test.tsx.snap | 635 ++--- src/components/__tests__/Searchbar.test.tsx | 275 +- .../__tests__/SearchbarUtils.test.tsx | 30 + .../__snapshots__/Searchbar.test.tsx.snap | 2250 +++++++---------- src/index.tsx | 3 +- 16 files changed, 2447 insertions(+), 2174 deletions(-) delete mode 100644 src/components/Searchbar.tsx create mode 100644 src/components/Searchbar/Searchbar.tsx create mode 100644 src/components/Searchbar/SearchbarResults.tsx create mode 100644 src/components/Searchbar/index.tsx create mode 100644 src/components/Searchbar/tokens.ts create mode 100644 src/components/Searchbar/utils.ts create mode 100644 src/components/__tests__/SearchbarUtils.test.tsx diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index e51de006aa..0919829020 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -25,6 +25,7 @@ The following props now accept animated styles returned from `useAnimatedStyle`. - `Searchbar`: `style` - `Snackbar`: `style` - `Surface`: `style` +- `ToggleButton`: `style` So you can use Reanimated's `useSharedValue` and `useAnimatedStyle` to animate these components instead of the React Native `Animated` API. @@ -70,41 +71,12 @@ You can use the component's color prop where available, or override the correspo ### Test IDs -Hardcoded default test IDs have been removed for the components listed below: - -- `Appbar.Content`: `appbar-content` -- `Appbar.Header`: `appbar-header` -- `BottomNavigation`: `bottom-navigation` -- `BottomNavigation.Bar`: `bottom-navigation-bar` -- `Button`: `button` -- `Card`: `card` -- `Chip`: `chip` -- `Drawer.CollapsedItem`: `drawer-collapsed-item` -- `FAB`: `floating-action-button` -- `FAB.Extended`: `extended-floating-action-button` -- `FAB.Menu`: `floating-action-button-menu` -- `IconButton`: `icon-button` -- `Menu`: `menu` -- `Menu.Item`: `menu-item` -- `Modal`: `modal` -- `ProgressBar`: `progress-bar` -- `Searchbar`: `search-bar` -- `Surface`: `surface` - -You can specify a `testID` explicitly to restore each component's own test ID. - -These components used to also derive test IDs for internal, implementation-only elements by appending a suffix to the `testID` prop (e.g. `${testID}-container`, `${testID}-icon`, `${testID}-outline`). They have been removed entirely. - -If you were relying on internal test IDs, update your tests not to rely on internal implementation details and only interact with elements or assert content your users can reach, e.g.: query by role, label, text etc., or `testID` props accepted by the component. - -Some components now accept explicit `testID` props for their interactable elements: - -- `BottomNavigation`: `barTestID` for the internal `BottomNavigation.Bar`, replacing the previous `${testID}-bar` derivation. -- `Chip`: `closeIconTestID` for the close icon button. -- `Dialog` and `Modal`: `overlayTestID` for the overlay displayed behind the content. -- `Menu`: `overlayTestID` for the overlay displayed behind the menu. -- `Searchbar`: `searchTestID`, `clearTestID`, and `trailingTestID` for the search, clear, and trailing icon buttons. -- `Snackbar`: `iconTestID` for the icon button. +Some hardcoded and generated test IDs have been removed for the following components: + +- `Appbar.Header`: `${testID}-root-layer` +- `Surface`: `surface` and `${testID}-outer-layer` + +You can specify a `testID` explicitly and use that value to query the component. ## Components @@ -189,26 +161,37 @@ e.g.: ### Searchbar -The misspelled `traileringIcon` props have been renamed: +The `Searchbar` modes use the latest Material 3 terminology in Paper 6.x: -- **`traileringIcon`** → **`trailingIcon`** -- **`traileringIconColor`** → **`trailingIconColor`** -- **`traileringIconAccessibilityLabel`** → **`trailingIconAccessibilityLabel`** -- **`onTraileringIconPress`** → **`onTrailingIconPress`** +- **`mode="bar"`** → **`mode="contained"`** +- **`mode="view"`** → **`mode="divided"`** -```diff +```tsx +// Before (v5) + + + +// After (v6) + + +``` + +The default `contained` mode adds 24dp horizontal margins and animates them to +12dp while focused. To keep a full-width Searchbar, provide a horizontal margin; +this replaces the built-in margin and disables the focus animation: + +```tsx ``` +Any horizontal margin, including `margin`, disables the built-in focus +animation. Use `marginVertical` when you only need vertical spacing and want to +keep the animated horizontal margins. + ### TextInput The Paper 6.x `TextInput` is a complete rewrite with a new API. Import the component the same way, but note that the props and behavior have changed significantly. @@ -329,31 +312,3 @@ const theme = { style={{ fontSize: 16, color: '#1C1B1F' }} /> ``` - -### ToggleButton - -`ToggleButton`, `ToggleButton.Group` and `ToggleButton.Row` were removed. For an -icon-only toggle, use `IconButton` with the `selected` prop. For a set of -mutually exclusive options, use `SegmentedButtons`. - -```tsx -// Before (v5) - - - - - -// After (v6) -<> - setValue('bold')} - /> - setValue('italic')} - /> - -``` diff --git a/docs/component-docs.config.ts b/docs/component-docs.config.ts index 2cf106849c..8181c9c46b 100644 --- a/docs/component-docs.config.ts +++ b/docs/component-docs.config.ts @@ -130,7 +130,10 @@ const pages = { RadioButtonIOS: 'RadioButton/RadioButtonIOS', RadioButtonItem: 'RadioButton/RadioButtonItem', }, - Searchbar: 'Searchbar', + Searchbar: { + Searchbar: 'Searchbar/Searchbar', + SearchbarResults: 'Searchbar/SearchbarResults', + }, SegmentedButtons: { SegmentedButtons: 'SegmentedButtons/SegmentedButtons', }, diff --git a/docs/src/data/screenshots.ts b/docs/src/data/screenshots.ts index b7c59d424d..4c7805b8f6 100644 --- a/docs/src/data/screenshots.ts +++ b/docs/src/data/screenshots.ts @@ -126,8 +126,8 @@ export const screenshots = { }, 'RadioButton.Item': 'screenshots/radio-item.ios.png', Searchbar: { - bar: 'screenshots/searchbar.png', - view: 'screenshots/searchbar-view.png', + contained: 'screenshots/searchbar.png', + divided: 'screenshots/searchbar-view.png', }, SegmentedButtons: { 'single select': 'screenshots/segmented-button-single-select.png', diff --git a/docs/src/data/themeColors.ts b/docs/src/data/themeColors.ts index 118114da27..28545e57a4 100644 --- a/docs/src/data/themeColors.ts +++ b/docs/src/data/themeColors.ts @@ -272,11 +272,11 @@ export const themeColors = { }, Searchbar: { '-': { - backgroundColor: 'theme.colors.elevation.level3', - placeholderTextColor: 'theme.colors.onSurface', - textColor: 'theme.colors.onSurfaceVariant', + backgroundColor: 'theme.colors.surfaceContainerHigh', + placeholderTextColor: 'theme.colors.onSurfaceVariant', + textColor: 'theme.colors.onSurface', selectionColor: 'theme.colors.primary', - iconColor: 'theme.colors.onSurfaceVariant', + iconColor: 'theme.colors.onSurface', trailingIconColor: 'theme.colors.onSurfaceVariant', dividerColor: 'theme.colors.outline', }, diff --git a/example/src/Examples/SearchbarExample.tsx b/example/src/Examples/SearchbarExample.tsx index 1a3e98bb8f..aaba106293 100644 --- a/example/src/Examples/SearchbarExample.tsx +++ b/example/src/Examples/SearchbarExample.tsx @@ -19,8 +19,8 @@ const SearchExample = () => { const [isVisible, setIsVisible] = React.useState(false); const [searchQueries, setSearchQuery] = React.useState({ searchBarMode: '', - trailingIcon: '', - trailingIconWithRightItem: '', + traileringIcon: '', + traileringIconWithRightItem: '', rightItem: '', loadingBarMode: '', searchViewMode: '', @@ -28,7 +28,7 @@ const SearchExample = () => { loadingViewMode: '', clickableBack: '', clickableDrawer: '', - clickableLoading: '', + withResults: '', }); const { colors } = useTheme(); @@ -36,7 +36,7 @@ const SearchExample = () => { return ( <> - + @@ -44,39 +44,39 @@ const SearchExample = () => { } value={searchQueries.searchBarMode} style={styles.searchbar} - mode="bar" + mode="contained" /> - setSearchQuery({ ...searchQueries, trailingIcon: query }) + setSearchQuery({ ...searchQueries, traileringIcon: query }) } - value={searchQueries.trailingIcon} - trailingIcon={'microphone'} - trailingIconColor={ + value={searchQueries.traileringIcon} + traileringIcon={'microphone'} + traileringIconColor={ isVisible ? Palette.error40 : colors.onSurfaceVariant } - trailingIconAccessibilityLabel={'microphone button'} - onTrailingIconPress={() => setIsVisible(true)} + traileringIconAccessibilityLabel={'microphone button'} + onTraileringIconPress={() => setIsVisible(true)} style={styles.searchbar} - mode="bar" + mode="contained" /> setSearchQuery({ ...searchQueries, - trailingIconWithRightItem: query, + traileringIconWithRightItem: query, }) } - value={searchQueries.trailingIconWithRightItem} - trailingIcon={'microphone'} - trailingIconColor={ + value={searchQueries.traileringIconWithRightItem} + traileringIcon={'microphone'} + traileringIconColor={ isVisible ? Palette.error40 : colors.onSurfaceVariant } - trailingIconAccessibilityLabel={'microphone button'} - onTrailingIconPress={() => setIsVisible(true)} + traileringIconAccessibilityLabel={'microphone button'} + onTraileringIconPress={() => setIsVisible(true)} right={(props) => ( { style={styles.searchbar} /> setSearchQuery({ @@ -115,12 +115,12 @@ const SearchExample = () => { } value={searchQueries.loadingBarMode} style={styles.searchbar} - mode="bar" + mode="contained" loading - trailingIcon={'microphone'} + traileringIcon={'microphone'} /> - + @@ -131,7 +131,7 @@ const SearchExample = () => { } value={searchQueries.searchViewMode} style={styles.searchbar} - mode="view" + mode="divided" /> { } value={searchQueries.searchWithoutBottomLine} style={styles.searchbar} - mode="view" + mode="divided" showDivider={false} /> { } value={searchQueries.loadingViewMode} style={styles.searchbar} - mode="view" + mode="divided" loading /> @@ -196,18 +196,30 @@ const SearchExample = () => { icon="menu" style={styles.searchbar} /> + + - setSearchQuery({ - ...searchQueries, - clickableLoading: query, - }) + setSearchQuery({ ...searchQueries, withResults: query }) } - value={searchQueries.clickableLoading} - loading + value={searchQueries.withResults} style={styles.searchbar} + mode="contained" /> + {searchQueries.withResults ? ( + + {['Apple', 'Apricot', 'Avocado', 'Banana', 'Blueberry'] + .filter((fruit) => + fruit + .toLowerCase() + .includes(searchQueries.withResults.toLowerCase()) + ) + .map((fruit) => ( + + ))} + + ) : null} & { - /** - * Hint text shown when the input is empty. - */ - placeholder?: string; - /** - * The value of the text input. - */ - value: string; - /** - * Callback that is called when the text input's text changes. - */ - onChangeText?: (query: string) => void; - /** - * @supported Available in v5.x with theme version 3 - * Search layout mode, the default value is "bar". - */ - mode?: 'bar' | 'view'; - /** - * Icon name for the left icon button (see `onIconPress`). - */ - icon?: IconSource; - /** - * Custom color for icon, default will be derived from theme - */ - iconColor?: ColorValue; - /** - * Callback to execute if we want the left icon to act as button. - */ - onIconPress?: (e: GestureResponderEvent) => void; - - /** - * Callback to execute if we want to add custom behaviour to close icon button. - */ - onClearIconPress?: (e: GestureResponderEvent) => void; - /** - * Accessibility label for the button. This is read by the screen reader when the user taps the button. - */ - searchAccessibilityLabel?: string; - /** - * testID for the left icon button (see `onIconPress`). - */ - searchTestID?: string; - /** - * Custom icon for clear button, default will be icon close. It's visible when `loading` is set to `false`. - * In v5.x with theme version 3, `clearIcon` is visible only if `right` prop is not defined. - */ - clearIcon?: IconSource; - /** - * Accessibility label for the button. This is read by the screen reader when the user taps the button. - */ - clearAccessibilityLabel?: string; - /** - * testID for the clear button. - */ - clearTestID?: string; - /** - * @supported Available in v5.x with theme version 3 - * Icon name for the right trailing icon button. - * Works only when `mode` is set to "bar". It won't be displayed if `loading` is set to `true`. - */ - trailingIcon?: IconSource; - /** - * @supported Available in v5.x with theme version 3 - * Custom color for the right trailing icon, default will be derived from theme - */ - trailingIconColor?: ColorValue; - /** - * Callback to execute on the right trailing icon button press. - */ - onTrailingIconPress?: (e: GestureResponderEvent) => void; - /** - * Accessibility label for the right trailing icon button. This is read by the screen reader when the user taps the button. - */ - trailingIconAccessibilityLabel?: string; - /** - * testID for the right trailing icon button. - */ - trailingTestID?: string; - /** - * @supported Available in v5.x with theme version 3 - * Callback which returns a React element to display on the right side. - * Works only when `mode` is set to "bar". - */ - right?: (props: { - color: ColorValue; - style: Style; - testID?: string; - }) => React.ReactNode; - /** - * @supported Available in v5.x with theme version 3 - * Whether to show `Divider` at the bottom of the search. - * Works only when `mode` is set to "view". True by default. - */ - showDivider?: boolean; - /** - * @supported Available in v5.x with theme version 3 - * Changes Searchbar shadow and background on iOS and Android. - */ - elevation?: Elevation; - /** - * Set style of the TextInput component inside the searchbar - */ - inputStyle?: StyleProp; - style?: StyleProp; - /** - * Custom flag for replacing clear button with activity indicator. - */ - loading?: Boolean; - /** - * TestID used for testing purposes - */ - testID?: string; - /** - * @optional - */ - theme?: ThemeProp; - ref?: React.Ref; -}; - -type TextInputHandles = Pick< - TextInput, - 'setNativeProps' | 'isFocused' | 'clear' | 'blur' | 'focus' | 'setSelection' ->; - -/** - * Searchbar is a simple input box where users can type search queries. - * - * ## Usage - * ```js - * import * as React from 'react'; - * import { Searchbar } from 'react-native-paper'; - * - * const MyComponent = () => { - * const [searchQuery, setSearchQuery] = React.useState(''); - * - * return ( - * - * ); - * }; - * - * export default MyComponent; - - * ``` - */ -const Searchbar = ({ - icon, - iconColor: customIconColor, - onIconPress, - searchAccessibilityLabel = 'search', - searchTestID, - clearIcon, - clearAccessibilityLabel = 'clear', - clearTestID, - onClearIconPress, - trailingIcon, - trailingIconColor, - trailingIconAccessibilityLabel, - trailingTestID, - onTrailingIconPress, - right, - mode = 'bar', - showDivider = true, - inputStyle, - placeholder, - elevation = 0, - style, - theme: themeOverrides, - value, - loading = false, - testID, - ref, - ...rest -}: Props) => { - const theme = useInternalTheme(themeOverrides); - - const { direction } = useLocale(); - const { colors, fonts } = theme; - - const root = React.useRef(null); - - React.useImperativeHandle(ref, () => ({ - focus: () => root.current?.focus(), - clear: () => root.current?.clear(), - setNativeProps: (args: TextInputProps) => - root.current?.setNativeProps(args), - isFocused: () => root.current?.isFocused() || false, - blur: () => root.current?.blur(), - setSelection: (start: number, end: number) => - root.current?.setSelection(start, end), - })); - - const handleClearPress = (e: any) => { - root.current?.clear(); - rest.onChangeText?.(''); - onClearIconPress?.(e); - }; - - const { dark } = theme; - - const placeholderTextColor = theme.colors.onSurface; - const textColor = theme.colors.onSurfaceVariant; - const iconColor = customIconColor || theme.colors.onSurfaceVariant; - - const font = { - ...fonts.bodyLarge, - lineHeight: Platform.select({ - ios: 0, - default: fonts.bodyLarge.lineHeight, - }), - }; - - const isBarMode = mode === 'bar'; - const inputTextAlign = direction === 'rtl' ? 'right' : 'left'; - const shouldRenderTrailingIcon = - isBarMode && trailingIcon && !loading && (!value || right !== undefined); - - return ( - - ( - - )) - } - theme={theme} - aria-label={searchAccessibilityLabel} - testID={searchTestID} - /> - - {loading ? ( - - ) : ( - // Clear icon should be always rendered within Searchbar – it's transparent, - // without touch events, when there is no value. It's done to avoid issues - // with the abruptly stopping ripple effect and changing bar width on web, - // when clearing the value. - - ( - - )) - } - role="button" - theme={theme} - testID={clearTestID} - /> - - )} - {shouldRenderTrailingIcon ? ( - - ) : null} - {isBarMode && - right?.({ color: textColor, style: styles.rightStyle, testID })} - {!isBarMode && showDivider && ( - - )} - - ); -}; - -const styles = StyleSheet.create({ - container: { - flexDirection: 'row', - alignItems: 'center', - }, - input: { - flex: 1, - fontSize: 18, - paddingLeft: 8, - alignSelf: 'stretch', - minWidth: 0, - }, - barModeInput: { - paddingLeft: 0, - minHeight: 56, - }, - viewModeInput: { - paddingLeft: 0, - minHeight: 72, - }, - v3Loader: { - marginHorizontal: 16, - }, - rightStyle: { - marginRight: 16, - }, - v3ClearIcon: { - position: 'absolute', - right: 0, - marginLeft: 16, - }, - v3ClearIconHidden: { - display: 'none', - }, - divider: { - position: 'absolute', - bottom: 0, - width: '100%', - }, -}); - -export default Searchbar; diff --git a/src/components/Searchbar/Searchbar.tsx b/src/components/Searchbar/Searchbar.tsx new file mode 100644 index 0000000000..4cbe868b92 --- /dev/null +++ b/src/components/Searchbar/Searchbar.tsx @@ -0,0 +1,612 @@ +import * as React from 'react'; +import { Platform, StyleSheet, TextInput, View } from 'react-native'; +import type { + ColorValue, + GestureResponderEvent, + StyleProp, + TextInputProps, + TextStyle, + ViewStyle, +} from 'react-native'; + +import Reanimated, { + isSharedValue, + ReduceMotion, + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; +import type { AnimatedStyle } from 'react-native-reanimated'; + +import { SearchbarTokens } from './tokens'; +import { getSearchbarColors, getSearchbarInputFont } from './utils'; +import { useLocale } from '../../core/locale'; +import { useInternalTheme } from '../../core/theming'; +import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; +import { toRawSpring } from '../../theme/tokens/sys/motion'; +import type { Elevation, ThemeProp } from '../../theme/types'; +import { resolveCornerRadius } from '../../theme/utils/shape'; +import { splitStyles } from '../../utils/splitStyles'; +import ActivityIndicator from '../ActivityIndicator'; +import Divider from '../Divider'; +import type { IconSource } from '../Icon'; +import IconButton from '../IconButton/IconButton'; +import MaterialCommunityIcon from '../MaterialCommunityIcon'; +import Surface from '../Surface'; +import type { SurfaceStyle } from '../Surface'; + +interface Style { + marginRight: number; +} + +const OUTER_LAYOUT_STYLE_KEYS: (keyof ViewStyle)[] = [ + 'position', + 'alignSelf', + 'top', + 'right', + 'bottom', + 'left', + 'start', + 'end', + 'flex', + 'flexBasis', + 'flexShrink', + 'flexGrow', + 'width', + 'minWidth', + 'maxWidth', + 'height', + 'minHeight', + 'maxHeight', + 'aspectRatio', + 'transform', + 'opacity', + 'zIndex', + 'display', +]; + +const VERTICAL_FILL_STYLE_KEYS: (keyof ViewStyle)[] = [ + 'height', + 'minHeight', + 'maxHeight', + 'flex', + 'flexGrow', + 'flexBasis', +]; + +const HORIZONTAL_MARGIN_KEYS = [ + 'margin', + 'marginHorizontal', + 'marginLeft', + 'marginRight', + 'marginStart', + 'marginEnd', + 'marginInline', + 'marginInlineStart', + 'marginInlineEnd', +] as const; + +/** + * Whether a style entry hides its values from the JS thread, i.e. whether it is + * a Reanimated animated style: either the opaque handle returned by + * `useAnimatedStyle`, or a plain object holding shared values. Such an entry + * cannot be read during render and `StyleSheet.flatten` would silently drop it, + * so it has to be forwarded to a Reanimated view untouched. + */ +function isAnimatedStyle(style: object) { + return 'viewDescriptors' in style || Object.values(style).some(isSharedValue); +} + +type StyleEntry = StyleProp | readonly StyleProp[]; + +/** + * Split the `style` prop into the part that can be inspected during render and + * the animated entries that can't, keeping the latter intact. + */ +function splitAnimatedStyles( + style: StyleProp +): [ViewStyle, StyleProp>[]] { + const staticStyles: ViewStyle[] = []; + const animatedStyles: StyleProp>[] = []; + + const collect = (entry: StyleEntry) => { + if (!entry) { + return; + } + + if (Array.isArray(entry)) { + entry.forEach(collect); + return; + } + + if (typeof entry === 'object' && isAnimatedStyle(entry)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + animatedStyles.push(entry as StyleProp>); + return; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + staticStyles.push(entry as ViewStyle); + }; + + collect(style); + + return [StyleSheet.flatten(staticStyles), animatedStyles]; +} + +export type Props = Omit & { + /** + * Hint text shown when the input is empty. + */ + placeholder?: string; + /** + * The value of the text input. + */ + value: string; + /** + * Callback that is called when the text input's text changes. + */ + onChangeText?: (query: string) => void; + /** + * Search layout mode, the default value is "contained". + * - `contained` - the recommended M3 Expressive style: a rounded, elevated + * bar whose horizontal margins animate from 24dp down to 12dp on focus + * (grow-wider effect). Providing any horizontal margin via `style` + * replaces the built-in margin and disables the focus transition. + * - `divided` - a full-bleed search view with square corners and a bottom + * `Divider`. Deprecated in M3 Expressive in favor of `contained`. + */ + mode?: 'contained' | 'divided'; + /** + * Icon name for the left icon button (see `onIconPress`). + */ + icon?: IconSource; + /** + * Custom color for icon, default will be derived from theme + */ + iconColor?: ColorValue; + /** + * Callback to execute if we want the left icon to act as button. + */ + onIconPress?: (e: GestureResponderEvent) => void; + + /** + * Callback to execute if we want to add custom behaviour to close icon button. + */ + onClearIconPress?: (e: GestureResponderEvent) => void; + /** + * Accessibility label for the button. This is read by the screen reader when the user taps the button. + */ + searchAccessibilityLabel?: string; + /** + * Custom icon for clear button, default will be icon close. It's visible when `loading` is set to `false`. + * In v5.x with theme version 3, `clearIcon` is visible only if `right` prop is not defined. + */ + clearIcon?: IconSource; + /** + * Accessibility label for the button. This is read by the screen reader when the user taps the button. + */ + clearAccessibilityLabel?: string; + /** + * Icon name for the right trailering icon button. + * Works only when `mode` is set to "contained". It won't be displayed if `loading` is set to `true`. + */ + traileringIcon?: IconSource; + /** + * Custom color for the right trailering icon, default will be derived from theme + */ + traileringIconColor?: ColorValue; + /** + * Callback to execute on the right trailering icon button press. + */ + onTraileringIconPress?: (e: GestureResponderEvent) => void; + /** + * Accessibility label for the right trailering icon button. This is read by the screen reader when the user taps the button. + */ + traileringIconAccessibilityLabel?: string; + /** + * Callback which returns a React element to display on the right side. + * Works only when `mode` is set to "contained". + */ + right?: (props: { + color: ColorValue; + style: Style; + testID: string; + }) => React.ReactNode; + /** + * Whether to show `Divider` at the bottom of the search. + * Works only when `mode` is set to "divided". True by default. + */ + showDivider?: boolean; + /** + * Changes Searchbar shadow and background on iOS and Android. + */ + elevation?: Elevation; + /** + * Set style of the TextInput component inside the searchbar + */ + inputStyle?: StyleProp; + /** + * Style of the Searchbar. + * + * Layout and margin properties are applied to the outer wrapper, the rest to + * the `Surface` that draws the bar. Background color and border radius should + * be set through the `theme` instead. + * + * Reanimated styles are supported and are applied to the outer wrapper. Their + * values can't be read during render, so an animated horizontal margin does + * not replace the built-in focus margin the way a static one does. + */ + style?: StyleProp; + /** + * Custom flag for replacing clear button with activity indicator. + */ + loading?: Boolean; + /** + * TestID used for testing purposes + */ + testID?: string; + /** + * @optional + */ + theme?: ThemeProp; + ref?: React.Ref; +}; + +type TextInputHandles = Pick< + TextInput, + 'setNativeProps' | 'isFocused' | 'clear' | 'blur' | 'focus' | 'setSelection' +>; + +/** + * Searchbar is a simple input box where users can type search queries. + * + * ## Usage + * ```js + * import * as React from 'react'; + * import { Searchbar } from 'react-native-paper'; + * + * const MyComponent = () => { + * const [searchQuery, setSearchQuery] = React.useState(''); + * + * return ( + * + * ); + * }; + * + * export default MyComponent; + + * ``` + */ +const Searchbar = ({ + icon, + iconColor: customIconColor, + onIconPress, + searchAccessibilityLabel = 'search', + clearIcon, + clearAccessibilityLabel = 'clear', + onClearIconPress, + traileringIcon, + traileringIconColor, + traileringIconAccessibilityLabel, + onTraileringIconPress, + right, + mode = 'contained', + showDivider = true, + inputStyle, + placeholder, + elevation = 0, + style, + theme: themeOverrides, + value, + loading = false, + testID, + onFocus, + onBlur, + ref, + ...rest +}: Props) => { + const theme = useInternalTheme(themeOverrides); + const { direction } = useLocale(); + const reduceMotion = useReduceMotion(); + const root = React.useRef(null); + + React.useImperativeHandle(ref, () => ({ + focus: () => root.current?.focus(), + clear: () => root.current?.clear(), + setNativeProps: (args: TextInputProps) => + root.current?.setNativeProps(args), + isFocused: () => root.current?.isFocused() || false, + blur: () => root.current?.blur(), + setSelection: (start: number, end: number) => + root.current?.setSelection(start, end), + })); + + const handleClearPress = (e: any) => { + root.current?.clear(); + rest.onChangeText?.(''); + onClearIconPress?.(e); + }; + + const { dark } = theme; + + const { + containerColor, + inputColor, + placeholderColor, + leadingIconColor, + trailingIconColor, + cursorColor, + dividerColor, + } = React.useMemo(() => getSearchbarColors(theme), [theme]); + const iconColor = customIconColor || leadingIconColor; + + const font = getSearchbarInputFont(theme); + + const isContained = mode === 'contained'; + const inputTextAlign = direction === 'rtl' ? 'right' : 'left'; + const shouldRenderTraileringIcon = + isContained && + traileringIcon && + !loading && + (!value || right !== undefined); + + const borderRadius = resolveCornerRadius( + theme, + isContained ? SearchbarTokens.contained : SearchbarTokens.divided + ); + + const reanimatedReduceMotion = reduceMotion + ? ReduceMotion.Always + : ReduceMotion.Never; + + // M3 Expressive focus transition: the contained bar grows wider on focus as + // its horizontal margin shrinks from `marginUnfocused` to `marginFocused`. + const focusMargin = useSharedValue(SearchbarTokens.marginUnfocused); + + const growSpring = React.useMemo( + () => ({ + ...toRawSpring(theme.motion.spring.fast.spatial), + reduceMotion: reanimatedReduceMotion, + }), + [theme.motion.spring.fast.spatial, reanimatedReduceMotion] + ); + const shrinkSpring = React.useMemo( + () => ({ + ...toRawSpring(theme.motion.spring.default.spatial), + reduceMotion: reanimatedReduceMotion, + }), + [theme.motion.spring.default.spatial, reanimatedReduceMotion] + ); + + const handleFocus: NonNullable = (e) => { + focusMargin.value = withSpring(SearchbarTokens.marginFocused, growSpring); + onFocus?.(e); + }; + + const handleBlur: NonNullable = (e) => { + focusMargin.value = withSpring( + SearchbarTokens.marginUnfocused, + shrinkSpring + ); + onBlur?.(e); + }; + + // Long-form margins on purpose: reanimated's web updater bypasses + // react-native-web's preprocessing, which is the only place the + // `marginHorizontal` shorthand gets expanded — as a raw DOM style + // it would be silently ignored. + const containedMarginStyle = useAnimatedStyle(() => ({ + marginLeft: focusMargin.value, + marginRight: focusMargin.value, + })); + + const [staticStyle, animatedStyles] = splitAnimatedStyles(style); + + // A consumer-provided horizontal margin wins over the built-in one and + // disables the focus transition. + const hasCustomHorizontalMargin = HORIZONTAL_MARGIN_KEYS.some( + (key) => staticStyle[key] !== undefined + ); + const applyFocusMargin = isContained && !hasCustomHorizontalMargin; + const [surfaceStyle, wrapperStyle] = splitStyles( + staticStyle, + (key) => OUTER_LAYOUT_STYLE_KEYS.includes(key) || key.startsWith('margin') + ); + const shouldFillWrapper = VERTICAL_FILL_STYLE_KEYS.some( + (key) => staticStyle[key] !== undefined + ); + const hasWrapperStyle = Object.keys(wrapperStyle).length > 0; + const outerStyle = + hasWrapperStyle || animatedStyles.length > 0 + ? [wrapperStyle, ...animatedStyles] + : null; + + return ( + + + + ( + + )) + } + theme={theme} + aria-label={searchAccessibilityLabel} + testID={testID ? `${testID}-icon` : undefined} + /> + + {loading ? ( + + ) : ( + // Clear icon should be always rendered within Searchbar – it's transparent, + // without touch events, when there is no value. It's done to avoid issues + // with the abruptly stopping ripple effect and changing bar width on web, + // when clearing the value. + + ( + + )) + } + testID={testID ? `${testID}-clear-icon` : undefined} + role="button" + theme={theme} + /> + + )} + {shouldRenderTraileringIcon ? ( + + ) : null} + {isContained && + right?.({ + color: trailingIconColor, + style: styles.rightStyle, + testID: testID ?? '', + })} + {!isContained && showDivider && ( + + )} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + }, + fillWrapper: { + flex: 1, + }, + input: { + flex: 1, + paddingLeft: SearchbarTokens.inputPaddingHorizontal, + alignSelf: 'stretch', + minWidth: 0, + }, + containedInput: { + paddingLeft: 0, + minHeight: SearchbarTokens.minHeight, + }, + dividedInput: { + paddingLeft: 0, + minHeight: SearchbarTokens.dividedMinHeight, + }, + v3Loader: { + marginHorizontal: 16, + }, + rightStyle: { + marginRight: 16, + }, + v3ClearIcon: { + position: 'absolute', + right: 0, + marginLeft: 16, + }, + v3ClearIconHidden: { + display: 'none', + }, + divider: { + position: 'absolute', + bottom: 0, + width: '100%', + }, +}); + +export default Searchbar; diff --git a/src/components/Searchbar/SearchbarResults.tsx b/src/components/Searchbar/SearchbarResults.tsx new file mode 100644 index 0000000000..0a46e14ed0 --- /dev/null +++ b/src/components/Searchbar/SearchbarResults.tsx @@ -0,0 +1,94 @@ +import * as React from 'react'; +import { StyleSheet } from 'react-native'; +import type { StyleProp } from 'react-native'; + +import { SearchbarTokens } from './tokens'; +import { getSearchbarColors } from './utils'; +import { useInternalTheme } from '../../core/theming'; +import type { Elevation, ThemeProp } from '../../theme/types'; +import { resolveCornerRadius } from '../../theme/utils/shape'; +import Surface from '../Surface'; +import type { SurfaceStyle } from '../Surface'; + +export type Props = { + /** + * Search results / suggestions to render inside the container. + */ + children: React.ReactNode; + /** + * Changes the container's shadow and background. + */ + elevation?: Elevation; + style?: StyleProp; + /** + * @optional + */ + theme?: ThemeProp; + /** + * TestID used for testing purposes + */ + testID?: string; +}; + +/** + * A container for the search results / suggestions list shown below a + * `Searchbar` (MD3 search anatomy element 6). It only provides the surface; + * grouping results with gaps is left to the consumer. + * + * ## Usage + * ```js + * import * as React from 'react'; + * import { Searchbar, List } from 'react-native-paper'; + * + * const MyComponent = () => { + * const [query, setQuery] = React.useState(''); + * + * return ( + * <> + * + * + * + * + * + * + * ); + * }; + * + * export default MyComponent; + * ``` + */ +const SearchbarResults = ({ + children, + elevation = 0, + style, + theme: themeOverrides, + testID, +}: Props) => { + const theme = useInternalTheme(themeOverrides); + const { resultsContainerColor } = React.useMemo( + () => getSearchbarColors(theme), + [theme] + ); + const borderRadius = resolveCornerRadius(theme, SearchbarTokens.results); + + return ( + + {children} + + ); +}; + +const styles = StyleSheet.create({ + container: { + width: '100%', + }, +}); + +export default SearchbarResults; diff --git a/src/components/Searchbar/index.tsx b/src/components/Searchbar/index.tsx new file mode 100644 index 0000000000..deedadf8e8 --- /dev/null +++ b/src/components/Searchbar/index.tsx @@ -0,0 +1,13 @@ +import SearchbarComponent from './Searchbar'; +import SearchbarResults from './SearchbarResults'; + +const Searchbar = Object.assign( + // @component ./Searchbar.tsx + SearchbarComponent, + { + // @component ./SearchbarResults.tsx + Results: SearchbarResults, + } +); + +export default Searchbar; diff --git a/src/components/Searchbar/tokens.ts b/src/components/Searchbar/tokens.ts new file mode 100644 index 0000000000..ccba7af324 --- /dev/null +++ b/src/components/Searchbar/tokens.ts @@ -0,0 +1,43 @@ +import type { ColorRole } from '../../theme/types'; +import type { ShapeToken } from '../../theme/utils/shape'; + +const sizes = { + /** Min height of the contained search bar (MD3: 56dp). */ + minHeight: 56, + /** Min height of the divided search view, which is taller. */ + dividedMinHeight: 72, + /** Horizontal gap between the input and its surrounding icons. */ + inputPaddingHorizontal: 8, + /** + * Horizontal margin around the contained bar when unfocused. On focus it + * animates down to `marginFocused`, producing the M3 Expressive + * "grow wider" effect. + */ + marginUnfocused: 24, + /** Horizontal margin around the contained bar when focused. */ + marginFocused: 12, +} as const; + +const shape = { + /** Contained search bar: fully rounded (28dp). */ + contained: 'extraLarge', + /** Divided search view: square corners. */ + divided: 'none', + /** Contained docked results: medium corners (12dp). */ + results: 'medium', +} as const satisfies Record; + +const colors = { + container: 'surfaceContainerHigh', + // Input/placeholder colors per MD3 — previously swapped in the legacy + // component (input was onSurfaceVariant, placeholder was onSurface). + input: 'onSurface', + placeholder: 'onSurfaceVariant', + leadingIcon: 'onSurface', + trailingIcon: 'onSurfaceVariant', + cursor: 'primary', + divider: 'outline', + resultsContainer: 'surfaceContainerHigh', +} as const satisfies Record; + +export const SearchbarTokens = { ...sizes, ...shape, ...colors }; diff --git a/src/components/Searchbar/utils.ts b/src/components/Searchbar/utils.ts new file mode 100644 index 0000000000..c4d959e118 --- /dev/null +++ b/src/components/Searchbar/utils.ts @@ -0,0 +1,51 @@ +import { Platform } from 'react-native'; +import type { ColorValue, TextStyle } from 'react-native'; + +import { SearchbarTokens } from './tokens'; +import type { InternalTheme } from '../../theme/types'; + +export type SearchbarColors = { + containerColor: ColorValue; + inputColor: ColorValue; + placeholderColor: ColorValue; + leadingIconColor: ColorValue; + trailingIconColor: ColorValue; + cursorColor: ColorValue; + dividerColor: ColorValue; + resultsContainerColor: ColorValue; +}; + +/** + * Resolve the Searchbar color-role tokens to concrete theme color values. + * Note `inputColor` is `onSurface` and `placeholderColor` is + * `onSurfaceVariant` — the MD3 spec order, fixing the legacy swap. + */ +export function getSearchbarColors(theme: InternalTheme): SearchbarColors { + const t = SearchbarTokens; + const c = theme.colors; + return { + containerColor: c[t.container], + inputColor: c[t.input], + placeholderColor: c[t.placeholder], + leadingIconColor: c[t.leadingIcon], + trailingIconColor: c[t.trailingIcon], + cursorColor: c[t.cursor], + dividerColor: c[t.divider], + resultsContainerColor: c[t.resultsContainer], + }; +} + +/** + * Resolve the input font from the `bodyLarge` typescale, zeroing `lineHeight` + * on iOS to avoid vertical clipping (mirrors the legacy behaviour). + */ +export function getSearchbarInputFont(theme: InternalTheme): TextStyle { + const { bodyLarge } = theme.fonts; + return { + ...bodyLarge, + lineHeight: Platform.select({ + ios: 0, + default: bodyLarge.lineHeight, + }), + }; +} diff --git a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap index faeb89916b..ef23ed3d4a 100644 --- a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap +++ b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap @@ -101,374 +101,393 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` /> - + - - magnify - + + + magnify + + + - - - - - - + - - close - + + + close + + + diff --git a/src/components/__tests__/Searchbar.test.tsx b/src/components/__tests__/Searchbar.test.tsx index ac39f78662..0062e6ff23 100644 --- a/src/components/__tests__/Searchbar.test.tsx +++ b/src/components/__tests__/Searchbar.test.tsx @@ -1,10 +1,39 @@ +import { Pressable, Text } from 'react-native'; + import { expect, it, jest } from '@jest/globals'; -import { userEvent } from '@testing-library/react-native'; +import { fireEvent, userEvent } from '@testing-library/react-native'; +import { + getAnimatedStyle, + useAnimatedStyle, + useSharedValue, +} from 'react-native-reanimated'; import { render, screen } from '../../test-utils'; +import { LightTheme } from '../../theme/schemes'; import * as Avatar from '../Avatar/Avatar'; import Searchbar from '../Searchbar'; +const AnimatedSearchbar = () => { + const opacity = useSharedValue(0); + const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value })); + + return ( + <> + { + opacity.value = 1; + }} + /> + + + ); +}; + it('renders with placeholder', async () => { const tree = ( await render() @@ -36,33 +65,54 @@ it('renders with ActivityIndicator', async () => { it('renders without ActivityIndicator', async () => { await render(); - expect(screen.queryByRole('progressbar')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('activity-indicator')).not.toBeOnTheScreen(); }); -it('renders clear icon with custom color', async () => { +it('uses the trailing icon color for the clear icon', async () => { await render( ); + // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. + const iconComponent = screen.getByTestId('search-bar-icon-wrapper').props + .children; + + // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. + expect(iconComponent.props.iconColor).toBe( + LightTheme.colors.onSurfaceVariant + ); +}); + +it('renders clear icon wrapper, which can be the target of touch events, if search has value', async () => { + await render(); + expect( - screen.getByText('close', { includeHiddenElements: true }) - ).toHaveStyle({ color: 'purple' }); + // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. + screen.getByTestId('search-bar-icon-wrapper').props.pointerEvents + ).toBe('auto'); }); -it('does not respond to touch on the clear icon when search has no value', async () => { - const onClearIconPressMock = jest.fn(); +it('renders clear icon wrapper, which is never target of touch events, if search has no value', async () => { + await render(); + + expect( + // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. + screen.getByTestId('search-bar-icon-wrapper').props.pointerEvents + ).toBe('none'); +}); + +it('applies transform styles to the outer wrapper', async () => { await render( ); - await userEvent.press( - screen.getByLabelText('clear', { includeHiddenElements: true }) - ); - expect(onClearIconPressMock).not.toHaveBeenCalled(); + expect(screen.getByTestId('search-bar-wrapper')).toHaveStyle({ + transform: [{ scale: 1.5 }], + }); }); it('defines onClearIconPress action and checks if it is called when close button is pressed', async () => { @@ -74,12 +124,20 @@ it('defines onClearIconPress action and checks if it is called when close button onClearIconPress={onClearIconPressMock} /> ); - await userEvent.press(screen.getByLabelText('clear')); + await userEvent.press(screen.getByTestId('search-bar-clear-icon')); expect(onClearIconPressMock).toHaveBeenCalledTimes(1); }); -it('hides the clear icon when a custom right element is rendered', async () => { - await render( +it('renders clear icon wrapper, with appropriate style for v3', async () => { + const { rerender } = await render(); + + expect(screen.getByTestId('search-bar-icon-wrapper')).toHaveStyle({ + position: 'absolute', + right: 0, + marginLeft: 16, + }); + + await rerender( { /> ); - expect(screen.queryByLabelText('clear')).not.toBeOnTheScreen(); + expect( + screen.getByTestId('search-bar-icon-wrapper', { + includeHiddenElements: true, + }) + ).toHaveStyle({ display: 'none' }); }); -it('renders trailing icon when mode is set to "bar"', async () => { +it('renders trailering icon when mode is set to "contained"', async () => { await render( ); - expect(screen.getByLabelText('microphone')).toBeOnTheScreen(); + expect(screen.getByTestId('search-bar-trailering-icon')).toBeOnTheScreen(); }); -it('renders trailing icon with press functionality', async () => { - const onTrailingIconPressMock = jest.fn(); +it('renders trailering icon with press functionality', async () => { + const onTraileringIconPressMock = jest.fn(); await render( ); - await userEvent.press(screen.getByLabelText('microphone')); - expect(onTrailingIconPressMock).toHaveBeenCalledTimes(1); + await userEvent.press(screen.getByTestId('search-bar-trailering-icon')); + expect(onTraileringIconPressMock).toHaveBeenCalledTimes(1); }); -it('renders clear icon instead of trailing icon', async () => { +it('renders clear icon instead of trailering icon', async () => { const { rerender } = await render( ); - expect(screen.getByLabelText('microphone')).toBeOnTheScreen(); + expect(screen.getByTestId('search-bar-trailering-icon')).toBeOnTheScreen(); await rerender( ); - expect(screen.queryByLabelText('microphone')).not.toBeOnTheScreen(); - expect(screen.getByLabelText('clear')).toBeOnTheScreen(); + expect( + screen.queryByTestId('search-bar-trailering-icon') + ).not.toBeOnTheScreen(); + expect(screen.getByTestId('search-bar-icon-wrapper')).toBeOnTheScreen(); }); -it('renders searchbar in "view" mode', async () => { - const tree = (await render()).toJSON(); +it('renders searchbar in "divided" mode', async () => { + await render(); - expect(tree).toMatchSnapshot(); + expect(screen.getByTestId('search-bar-container')).toHaveStyle({ + borderRadius: 0, + }); +}); + +it('applies the unfocused container margin in "contained" mode', async () => { + await render(); + + expect(screen.getByTestId('search-bar-focus-wrapper')).toHaveStyle({ + marginLeft: 24, + marginRight: 24, + }); + expect(screen.getByTestId('search-bar-container')).not.toHaveStyle({ + marginLeft: 24, + marginRight: 24, + }); +}); + +it('does not apply the container margin in "divided" mode', async () => { + await render(); + + expect(screen.getByTestId('search-bar-focus-wrapper')).not.toHaveStyle({ + marginLeft: 24, + marginRight: 24, + }); +}); + +it('lets a custom horizontal margin win over the built-in one', async () => { + await render( + + ); + + expect(screen.getByTestId('search-bar-focus-wrapper')).not.toHaveStyle({ + marginLeft: 24, + marginRight: 24, + }); + expect(screen.getByTestId('search-bar-wrapper')).toHaveStyle({ + marginHorizontal: 0, + }); +}); + +it('lets a custom logical horizontal margin win over the built-in one', async () => { + await render( + + ); + + expect(screen.getByTestId('search-bar-wrapper')).toHaveStyle({ + marginInline: 0, + }); + expect(screen.getByTestId('search-bar-focus-wrapper')).not.toHaveStyle({ + marginLeft: 24, + marginRight: 24, + }); +}); + +it('keeps layout styles on the outermost element', async () => { + await render( + + ); + + const wrapper = screen.getByTestId('search-bar-wrapper'); + + expect(wrapper).toBe(screen.root); + expect(wrapper).toHaveStyle({ flex: 1, position: 'absolute', top: 0 }); + expect(screen.getByTestId('search-bar-container')).not.toHaveStyle({ + flex: 1, + position: 'absolute', + top: 0, + }); +}); + +it('applies Reanimated styles from the style prop to the outer wrapper', async () => { + await render(); + + const wrapper = screen.getByTestId('search-bar-wrapper'); + + expect(getAnimatedStyle(wrapper)).toMatchObject({ opacity: 0 }); + // Static entries are still split off and land on the Surface. + expect(screen.getByTestId('search-bar-container')).toHaveStyle({ + padding: 8, + }); + + await userEvent.press(screen.getByTestId('animate-search-bar')); + await jest.runAllTimersAsync(); + + expect(getAnimatedStyle(wrapper)).toMatchObject({ opacity: 1 }); +}); + +it('forwards onFocus and onBlur to the input', async () => { + const onFocus = jest.fn(); + const onBlur = jest.fn(); + await render( + + ); + + await fireEvent(screen.getByTestId('search-bar'), 'focus'); + await fireEvent(screen.getByTestId('search-bar'), 'blur'); + + expect(onFocus).toHaveBeenCalledTimes(1); + expect(onBlur).toHaveBeenCalledTimes(1); +}); + +it('renders a results container via Searchbar.Results', async () => { + await render( + + Result + + ); + + expect(screen.getByTestId('search-bar-results')).toBeOnTheScreen(); + expect(screen.getByTestId('search-bar-results')).toHaveStyle({ + borderRadius: LightTheme.shapes.corner.medium, + }); }); diff --git a/src/components/__tests__/SearchbarUtils.test.tsx b/src/components/__tests__/SearchbarUtils.test.tsx new file mode 100644 index 0000000000..9fe8a4feb9 --- /dev/null +++ b/src/components/__tests__/SearchbarUtils.test.tsx @@ -0,0 +1,30 @@ +import { describe, expect, it } from '@jest/globals'; + +import { LightTheme } from '../../theme/schemes'; +import { getSearchbarColors, getSearchbarInputFont } from '../Searchbar/utils'; + +describe('getSearchbarColors', () => { + it('resolves the MD3 color roles, with input/placeholder in spec order', () => { + const theme = LightTheme; + expect(getSearchbarColors(theme)).toEqual({ + containerColor: theme.colors.surfaceContainerHigh, + inputColor: theme.colors.onSurface, + placeholderColor: theme.colors.onSurfaceVariant, + leadingIconColor: theme.colors.onSurface, + trailingIconColor: theme.colors.onSurfaceVariant, + cursorColor: theme.colors.primary, + dividerColor: theme.colors.outline, + resultsContainerColor: theme.colors.surfaceContainerHigh, + }); + }); +}); + +describe('getSearchbarInputFont', () => { + it('uses the bodyLarge typescale with lineHeight zeroed on iOS', () => { + const theme = LightTheme; + expect(getSearchbarInputFont(theme)).toEqual({ + ...theme.fonts.bodyLarge, + lineHeight: 0, + }); + }); +}); diff --git a/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap b/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap index c200e71706..2edb0388cd 100644 --- a/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap @@ -3,284 +3,277 @@ exports[`activity indicator snapshot test 1`] = ` - + - - magnify - + + + magnify + + + - - - - - + underlineColorAndroid="transparent" + value="" + /> @@ -314,10 +307,7 @@ exports[`activity indicator snapshot test 1`] = ` "height": 24, "transform": [ { - "translateY": 0, - }, - { - "rotate": "-165deg", + "rotate": "45deg", }, ], "width": 24, @@ -338,55 +328,57 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "borderColor": "rgba(103, 80, 164, 1)", - "borderRadius": 12, - "borderWidth": 2.4, "height": 24, + "transform": [ + { + "translateY": 0, + }, + { + "rotate": "-165deg", + }, + ], "width": 24, } } - /> + > + + + + - - - - @@ -397,10 +389,7 @@ exports[`activity indicator snapshot test 1`] = ` "height": 24, "transform": [ { - "translateY": -12, - }, - { - "rotate": "345deg", + "rotate": "45deg", }, ], "width": 24, @@ -413,6 +402,7 @@ exports[`activity indicator snapshot test 1`] = ` { "height": 12, "overflow": "hidden", + "top": 12, "width": 24, } } @@ -421,14 +411,43 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "borderColor": "rgba(103, 80, 164, 1)", - "borderRadius": 12, - "borderWidth": 2.4, "height": 24, + "transform": [ + { + "translateY": -12, + }, + { + "rotate": "345deg", + }, + ], "width": 24, } } - /> + > + + + + @@ -439,780 +458,396 @@ exports[`activity indicator snapshot test 1`] = ` `; -exports[`renders searchbar in "view" mode 1`] = ` +exports[`renders with placeholder 1`] = ` - - - magnify - - - - - - - - + - - close - - - - - - - -`; - -exports[`renders with placeholder 1`] = ` - - - - - - - magnify - + + + magnify + + + - - - - - - + - - close - + + + close + + + @@ -1223,370 +858,389 @@ exports[`renders with placeholder 1`] = ` exports[`renders with text 1`] = ` - + - - magnify - + + + magnify + + + - - - - - - + - - close - + + + close + + + diff --git a/src/index.tsx b/src/index.tsx index 1807d2f249..a403334819 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -123,7 +123,8 @@ export type { Props as RadioButtonAndroidProps } from './components/RadioButton/ export type { Props as RadioButtonGroupProps } from './components/RadioButton/RadioButtonGroup'; export type { Props as RadioButtonIOSProps } from './components/RadioButton/RadioButtonIOS'; export type { Props as RadioButtonItemProps } from './components/RadioButton/RadioButtonItem'; -export type { Props as SearchbarProps } from './components/Searchbar'; +export type { Props as SearchbarProps } from './components/Searchbar/Searchbar'; +export type { Props as SearchbarResultsProps } from './components/Searchbar/SearchbarResults'; export type { Props as SnackbarProps } from './components/Snackbar'; export type { Props as SurfaceProps } from './components/Surface'; export type { Props as SwitchProps } from './components/Switch/Switch';