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';