diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md
index 72aee6d00f..29dfbf6e08 100644
--- a/docs/6.x/docs/guides/migration.md
+++ b/docs/6.x/docs/guides/migration.md
@@ -287,6 +287,39 @@ The `overlayAccessibilityLabel` prop was renamed to `dismissAccessibilityLabel`:
### Searchbar
+The `Searchbar` modes use the latest Material 3 terminology in Paper 6.x:
+
+- **`mode="bar"`** → **`mode="contained"`**
+- **`mode="view"`** → **`mode="divided"`**
+
+```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 margin for that
+axis; it replaces the built-in margin and disables the focus animation:
+
+```tsx
+
+```
+
+Only a margin naming that axis - `marginHorizontal`, `marginStart`, `marginEnd`,
+`marginLeft` or `marginRight` - replaces the animated one. The `margin`
+shorthand is less specific, so the horizontal margins keep animating around it.
+Use `marginVertical` when you only need vertical spacing and want to keep the
+animated horizontal margins.
+
The misspelled `traileringIcon` props have been renamed:
- **`traileringIcon`** → **`trailingIcon`**
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..85b89ea888 100644
--- a/example/src/Examples/SearchbarExample.tsx
+++ b/example/src/Examples/SearchbarExample.tsx
@@ -29,6 +29,7 @@ const SearchExample = () => {
clickableBack: '',
clickableDrawer: '',
clickableLoading: '',
+ withResults: '',
});
const { colors } = useTheme();
@@ -36,7 +37,7 @@ const SearchExample = () => {
return (
<>
-
+
@@ -44,7 +45,7 @@ const SearchExample = () => {
}
value={searchQueries.searchBarMode}
style={styles.searchbar}
- mode="bar"
+ mode="contained"
/>
{
trailingIconAccessibilityLabel={'microphone button'}
onTrailingIconPress={() => setIsVisible(true)}
style={styles.searchbar}
- mode="bar"
+ mode="contained"
/>
setSearchQuery({
@@ -87,7 +88,7 @@ const SearchExample = () => {
style={styles.searchbar}
/>
setSearchQuery({
@@ -115,12 +116,12 @@ const SearchExample = () => {
}
value={searchQueries.loadingBarMode}
style={styles.searchbar}
- mode="bar"
+ mode="contained"
loading
trailingIcon={'microphone'}
/>
-
+
@@ -131,7 +132,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
/>
@@ -209,6 +210,30 @@ const SearchExample = () => {
style={styles.searchbar}
/>
+
+
+ setSearchQuery({ ...searchQueries, withResults: query })
+ }
+ 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.
@@ -38,10 +88,17 @@ export type Props = Omit & {
*/
onChangeText?: (query: string) => void;
/**
- * @supported Available in v5.x with theme version 3
- * Search layout mode, the default value is "bar".
+ * Search layout mode, the default value is "contained".
+ * - `contained` - the recommended M3 Expressive style: a rounded bar
+ * whose horizontal margins animate from 24dp down to 12dp on focus
+ * (grow-wider effect). A `marginHorizontal`, `marginStart`, `marginEnd`,
+ * `marginLeft` or `marginRight` set through `style` replaces the animated
+ * margin on that side, so the bar keeps it. The `margin` shorthand does
+ * not, since a per-axis margin takes precedence over it.
+ * - `divided` - a full-bleed search view with square corners and a bottom
+ * `Divider`. Deprecated in M3 Expressive in favor of `contained`.
*/
- mode?: 'bar' | 'view';
+ mode?: 'contained' | 'divided';
/**
* Icon name for the left icon button (see `onIconPress`).
*/
@@ -81,13 +138,11 @@ export type Props = Omit & {
*/
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`.
+ * Works only when `mode` is set to "contained". 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;
@@ -104,9 +159,8 @@ export type Props = Omit & {
*/
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".
+ * Works only when `mode` is set to "contained".
*/
right?: (props: {
color: ColorValue;
@@ -114,13 +168,11 @@ export type Props = Omit & {
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.
+ * Works only when `mode` is set to "divided". 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;
@@ -128,6 +180,13 @@ export type Props = Omit & {
* Set style of the TextInput component inside the searchbar
*/
inputStyle?: StyleProp;
+ /**
+ * Style of the Searchbar.
+ *
+ * Applied to the `Surface` that draws the bar, along with any Reanimated
+ * style. Background color and border radius should be set through the
+ * `theme` instead.
+ */
style?: StyleProp;
/**
* Custom flag for replacing clear button with activity indicator.
@@ -184,12 +243,12 @@ const Searchbar = ({
clearTestID,
onClearIconPress,
trailingIcon,
- trailingIconColor,
+ trailingIconColor: customTrailingIconColor,
trailingIconAccessibilityLabel,
trailingTestID,
onTrailingIconPress,
right,
- mode = 'bar',
+ mode = 'contained',
showDivider = true,
inputStyle,
placeholder,
@@ -199,14 +258,14 @@ const Searchbar = ({
value,
loading = false,
testID,
+ onFocus,
+ onBlur,
ref,
...rest
}: Props) => {
const theme = useInternalTheme(themeOverrides);
-
const { direction } = useLocale();
- const { colors, fonts } = theme;
-
+ const reduceMotion = useReduceMotion();
const root = React.useRef(null);
React.useImperativeHandle(ref, () => ({
@@ -228,28 +287,107 @@ const Searchbar = ({
const { dark } = theme;
- const placeholderTextColor = theme.colors.onSurface;
- const textColor = theme.colors.onSurfaceVariant;
- const iconColor = customIconColor || theme.colors.onSurfaceVariant;
+ const {
+ containerColor,
+ inputColor,
+ placeholderColor,
+ leadingIconColor,
+ trailingIconColor,
+ cursorColor,
+ dividerColor,
+ } = React.useMemo(() => getSearchbarColors(theme), [theme]);
+ const iconColor = customIconColor || leadingIconColor;
- const font = {
- ...fonts.bodyLarge,
- lineHeight: Platform.select({
- ios: 0,
- default: fonts.bodyLarge.lineHeight,
- }),
- };
+ const font = getSearchbarInputFont(theme);
- const isBarMode = mode === 'bar';
+ const isContained = mode === 'contained';
const inputTextAlign = direction === 'rtl' ? 'right' : 'left';
const shouldRenderTrailingIcon =
- isBarMode && trailingIcon && !loading && (!value || right !== undefined);
+ isContained && trailingIcon && !loading && (!value || right !== undefined);
+
+ const borderRadius = resolveCornerRadius(
+ theme,
+ isContained ? SearchbarTokens.contained : SearchbarTokens.divided
+ );
+
+ // A two-state transition, so a Reanimated CSS transition on a plain style
+ // placed before `style`, which lets the consumer override the margin.
+ const [focused, setFocused] = React.useState(false);
+ // Pressing one of the bar's own buttons blurs the input on web, and
+ // collapsing then moves the button out from under the pointer. The bar stays
+ // expanded until focus leaves it entirely.
+ const [focusHeldInside, setFocusHeldInside] = React.useState(false);
+ // An object ref would reach `Surface` as a `forwardedRef` prop. Typed as an
+ // `HTMLElement` so `focusout` resolves to a typed `FocusEvent` listener.
+ const barNodeRef = React.useRef(null);
+ const expanded = focused || focusHeldInside;
+
+ const handleBarRef = React.useCallback((instance: View | null) => {
+ // On web react-native-web resolves a view ref to the DOM node it rendered.
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ barNodeRef.current = instance as unknown as HTMLElement | null;
+ }, []);
+
+ React.useEffect(() => {
+ if (!focusHeldInside || Platform.OS !== 'web') {
+ return;
+ }
+
+ const node = barNodeRef.current;
+
+ const handleFocusOut = (event: FocusEvent) => {
+ const staysInside =
+ event.relatedTarget instanceof Node &&
+ !!node?.contains(event.relatedTarget);
+ if (!staysInside) {
+ setFocusHeldInside(false);
+ }
+ };
+
+ node?.addEventListener('focusout', handleFocusOut);
+ return () => node?.removeEventListener('focusout', handleFocusOut);
+ }, [focusHeldInside]);
+
+ const handleFocus: NonNullable = (e) => {
+ setFocused(true);
+ onFocus?.(e);
+ };
+
+ const handleBlur: NonNullable = (e) => {
+ setFocused(false);
+ setFocusHeldInside(isFocusInsideBar(e, barNodeRef.current));
+ onBlur?.(e);
+ };
+
+ const focusTransitionStyle: CSSStyle = {
+ marginHorizontal: expanded
+ ? SearchbarTokens.marginFocused
+ : SearchbarTokens.marginUnfocused,
+ transitionProperty: [
+ // RNW writes this out as CSS `margin-inline`, which web has to name.
+ 'marginHorizontal',
+ ...(Platform.OS === 'web'
+ ? (['marginInline'] as SurfaceTransitionProperty[])
+ : []),
+ ...SURFACE_TRANSITION_PROPERTIES,
+ ],
+ transitionTimingFunction: cubicBezier(...theme.motion.easing.standard),
+ };
return (
@@ -277,17 +415,17 @@ const Searchbar = ({
style={[
styles.input,
{
- color: textColor,
+ color: inputColor,
...font,
...Platform.select({ web: { outline: 'none' } }),
textAlign: inputTextAlign,
},
- isBarMode ? styles.barModeInput : styles.viewModeInput,
+ isContained ? styles.containedInput : styles.dividedInput,
inputStyle,
]}
placeholder={placeholder || ''}
- placeholderTextColor={placeholderTextColor}
- selectionColor={colors.primary}
+ placeholderTextColor={placeholderColor}
+ selectionColor={cursorColor}
underlineColorAndroid="transparent"
returnKeyType="search"
keyboardAppearance={dark ? 'dark' : 'light'}
@@ -295,6 +433,8 @@ const Searchbar = ({
ref={root}
value={value}
testID={testID}
+ onFocus={handleFocus}
+ onBlur={handleBlur}
{...rest}
/>
{loading ? (
@@ -314,7 +454,7 @@ const Searchbar = ({
))
}
+ testID={clearTestID}
role="button"
theme={theme}
- testID={clearTestID}
/>
)}
@@ -338,21 +478,26 @@ const Searchbar = ({
role="button"
borderless
onPress={onTrailingIconPress}
- iconColor={trailingIconColor || colors.onSurfaceVariant}
+ iconColor={customTrailingIconColor || trailingIconColor}
icon={trailingIcon}
aria-label={trailingIconAccessibilityLabel}
+ theme={theme}
testID={trailingTestID}
/>
) : null}
- {isBarMode &&
- right?.({ color: textColor, style: styles.rightStyle, testID })}
- {!isBarMode && showDivider && (
+ {isContained &&
+ right?.({
+ color: trailingIconColor,
+ style: styles.rightStyle,
+ testID,
+ })}
+ {!isContained && showDivider && (
@@ -368,18 +513,17 @@ const styles = StyleSheet.create({
},
input: {
flex: 1,
- fontSize: 18,
- paddingLeft: 8,
+ paddingLeft: SearchbarTokens.inputPaddingHorizontal,
alignSelf: 'stretch',
minWidth: 0,
},
- barModeInput: {
+ containedInput: {
paddingLeft: 0,
- minHeight: 56,
+ minHeight: SearchbarTokens.minHeight,
},
- viewModeInput: {
+ dividedInput: {
paddingLeft: 0,
- minHeight: 72,
+ minHeight: SearchbarTokens.dividedMinHeight,
},
v3Loader: {
marginHorizontal: 16,
diff --git a/src/components/Searchbar/SearchbarResults.tsx b/src/components/Searchbar/SearchbarResults.tsx
new file mode 100644
index 0000000000..6a0935f74e
--- /dev/null
+++ b/src/components/Searchbar/SearchbarResults.tsx
@@ -0,0 +1,97 @@
+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. It carries the
+ * horizontal margin of a focused contained bar, so the two line up the way the
+ * MD3 docked search view does; pass a margin through `style` to change that.
+ *
+ * ## 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: {
+ // Margin rather than width: a width would add to it and overflow.
+ marginHorizontal: SearchbarTokens.marginFocused,
+ },
+});
+
+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..f16131205e 100644
--- a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap
+++ b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap
@@ -101,6 +101,7 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = `
/>
{
const tree = (
@@ -39,16 +49,31 @@ it('renders without ActivityIndicator', async () => {
expect(screen.queryByRole('progressbar')).not.toBeOnTheScreen();
});
-it('renders clear icon with custom color', async () => {
+it('uses the trailing color for the clear icon independently of the search icon', async () => {
await render(
);
expect(
screen.getByText('close', { includeHiddenElements: true })
+ ).toHaveStyle({ color: LightTheme.colors.onSurfaceVariant });
+ expect(
+ screen.getByText('magnify', { includeHiddenElements: true })
).toHaveStyle({ color: 'purple' });
});
+it('uses the MD3 color roles for the input and its placeholder', async () => {
+ const tree = (
+ await render()
+ ).toJSON();
+
+ // `placeholderTextColor` is an attribute, not styling: the snapshot locks it.
+ expect(screen.getByRole('searchbox')).toHaveStyle({
+ color: LightTheme.colors.onSurface,
+ });
+ expect(tree).toMatchSnapshot();
+});
+
it('does not respond to touch on the clear icon when search has no value', async () => {
const onClearIconPressMock = jest.fn();
await render(
@@ -90,14 +115,14 @@ it('hides the clear icon when a custom right element is rendered', async () => {
expect(screen.queryByLabelText('clear')).not.toBeOnTheScreen();
});
-it('renders trailing icon when mode is set to "bar"', async () => {
+it('renders trailing icon when mode is set to "contained"', async () => {
await render(
);
@@ -114,7 +139,7 @@ it('renders trailing icon with press functionality', async () => {
trailingIcon={'microphone'}
trailingIconAccessibilityLabel="microphone"
onTrailingIconPress={onTrailingIconPressMock}
- mode="bar"
+ mode="contained"
/>
);
@@ -129,7 +154,7 @@ it('renders clear icon instead of trailing icon', async () => {
value={''}
trailingIcon={'microphone'}
trailingIconAccessibilityLabel="microphone"
- mode="bar"
+ mode="contained"
/>
);
@@ -141,7 +166,7 @@ it('renders clear icon instead of trailing icon', async () => {
value={'test'}
trailingIcon={'microphone'}
trailingIconAccessibilityLabel="microphone"
- mode="bar"
+ mode="contained"
/>
);
@@ -149,8 +174,172 @@ it('renders clear icon instead of trailing icon', async () => {
expect(screen.getByLabelText('clear')).toBeOnTheScreen();
});
-it('renders searchbar in "view" mode', async () => {
- const tree = (await render()).toJSON();
+it('renders searchbar in "divided" mode', async () => {
+ const tree = (await render()).toJSON();
expect(tree).toMatchSnapshot();
});
+
+it('forwards explicit test IDs to the input and each icon button', async () => {
+ const { rerender } = await render(
+
+ );
+
+ expect(screen.getByTestId('query')).toBe(screen.getByRole('searchbox'));
+ expect(screen.getByTestId('search-button')).toBe(
+ screen.getByRole('button', { name: 'search' })
+ );
+ expect(screen.getByTestId('trailing-button')).toBeOnTheScreen();
+ expect(screen.queryAllByTestId(/^query-/)).toHaveLength(0);
+
+ await rerender();
+ expect(screen.getByTestId('clear-button')).toBe(
+ screen.getByRole('button', { name: 'clear' })
+ );
+});
+
+it('does not assign default test IDs', async () => {
+ await render();
+ expect(screen.queryAllByTestId(/./)).toHaveLength(0);
+});
+
+it('uses the custom trailing icon color', async () => {
+ await render(
+
+ );
+ expect(
+ screen.getByText('microphone', { includeHiddenElements: true })
+ ).toHaveStyle({ color: 'purple' });
+});
+
+it('applies layout styles to the outermost element', async () => {
+ await render(
+
+ );
+ expect(screen.root).toHaveStyle({
+ flex: 1,
+ position: 'absolute',
+ top: 0,
+ transform: [{ scale: 1.5 }],
+ });
+});
+
+it.each([{ marginHorizontal: 0 }, { marginInline: 0 }])(
+ 'respects custom horizontal margins %j',
+ async (style) => {
+ const tree = (await render()).toJSON();
+ expect(screen.root).toHaveStyle(style);
+ expect(tree).toMatchSnapshot();
+ }
+);
+
+it.each([{ height: 80 }, { flex: 1 }])(
+ 'fills a custom vertical layout %j',
+ async (style) => {
+ const tree = (await render()).toJSON();
+ expect(screen.root).toHaveStyle(style);
+ expect(tree).toMatchSnapshot();
+ }
+);
+
+it('applies Reanimated styles to the Searchbar', async () => {
+ const AnimatedSearchbar = () => {
+ const opacity = useSharedValue(0);
+ const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value }));
+
+ return (
+ (
+ {
+ opacity.value = 1;
+ }}
+ >
+ Animate
+
+ )}
+ />
+ );
+ };
+
+ await render();
+ const root = screen.root;
+ if (!root) {
+ throw new Error('Searchbar did not render');
+ }
+ expect(getAnimatedStyle(root)).toMatchObject({ opacity: 0 });
+
+ await userEvent.press(screen.getByLabelText('Animate Searchbar'));
+ await jest.runAllTimersAsync();
+
+ expect(getAnimatedStyle(root)).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.getByRole('searchbox'), 'focus');
+ await fireEvent(screen.getByRole('searchbox'), 'blur');
+
+ expect(onFocus).toHaveBeenCalledTimes(1);
+ expect(onBlur).toHaveBeenCalledTimes(1);
+});
+
+it('renders results below the Searchbar', async () => {
+ await render(
+
+ Result
+
+ );
+
+ expect(screen.getByText('Result')).toBeOnTheScreen();
+ expect(screen.getByTestId('results')).toHaveStyle({
+ borderRadius: LightTheme.shapes.corner.medium,
+ marginHorizontal: SearchbarTokens.marginFocused,
+ });
+});
+
+it('shrinks its horizontal margin while the input is focused', async () => {
+ await render();
+
+ expect(screen.root).toHaveStyle({
+ marginHorizontal: SearchbarTokens.marginUnfocused,
+ });
+
+ await fireEvent(screen.getByRole('searchbox'), 'focus');
+
+ expect(screen.root).toHaveStyle({
+ marginHorizontal: SearchbarTokens.marginFocused,
+ });
+});
+
+it('keeps a custom horizontal margin instead of animating it', async () => {
+ await render();
+
+ await fireEvent(screen.getByRole('searchbox'), 'focus');
+
+ expect(screen.root).toHaveStyle({
+ marginHorizontal: 40,
+ });
+});
diff --git a/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap b/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap
index c200e71706..a615199e39 100644
--- a/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap
+++ b/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap
@@ -3,6 +3,7 @@
exports[`activity indicator snapshot test 1`] = `
`;
-exports[`renders searchbar in "view" mode 1`] = `
+exports[`fills a custom vertical layout {"flex":1} 1`] = `
-
`;
-exports[`renders with placeholder 1`] = `
+exports[`fills a custom vertical layout {"height":80} 1`] = `
`;
-exports[`renders with text 1`] = `
+exports[`renders searchbar in "divided" mode 1`] = `
+
+
+
+
+
+ magnify
+
+
+
+
+
+
+
+
+
+
+ close
+
+
+
+
+
+
+
+`;
+
+exports[`renders with placeholder 1`] = `
+
+
+
+
+
+
+ magnify
+
+
+
+
+
+
+
+
+
+
+ close
+
+
+
+
+
+
+`;
+
+exports[`renders with text 1`] = `
+
+
+
+
+
+
+ magnify
+
+
+
+
+
+
+
+
+
+
+ close
+
+
+
+
+
+
+`;
+
+exports[`respects custom horizontal margins {"marginHorizontal":0} 1`] = `
+
+
+
+
+
+
+ magnify
+
+
+
+
+
+
+
+
+
+
+ close
+
+
+
+
+
+
+`;
+
+exports[`respects custom horizontal margins {"marginInline":0} 1`] = `
+
+
+
+
+
+
+ magnify
+
+
+
+
+
+
+
+
+
+
+ close
+
+
+
+
+
+
+`;
+
+exports[`uses the MD3 color roles for the input and its placeholder 1`] = `
+