From 8bf580e139c0e3a1d8bb3fef6e9a3cdbaa097b21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Muzyk?= Date: Wed, 1 Jul 2026 13:16:41 +0200 Subject: [PATCH 1/5] refactor: searchbar component --- .../components/{ => Searchbar}/Searchbar.mdx | 0 docs/5.x/docs/components/Searchbar/_meta.json | 3 + docs/5.x/docs/components/_meta.json | 8 +- .../components/{ => Searchbar}/Searchbar.mdx | 52 +- .../components/Searchbar/SearchbarResults.mdx | 93 ++ docs/6.x/docs/components/Searchbar/_meta.json | 4 + docs/6.x/docs/components/_meta.json | 8 +- docs/component-docs.config.ts | 5 +- docs/src/data/componentDocs6x.json | 120 +- docs/src/data/screenshots.ts | 4 +- docs/src/data/themeColors.ts | 6 +- example/src/Examples/SearchbarExample.tsx | 48 +- src/components/Searchbar.tsx | 401 ----- src/components/Searchbar/Searchbar.tsx | 464 ++++++ src/components/Searchbar/SearchbarResults.tsx | 90 ++ src/components/Searchbar/index.tsx | 13 + src/components/Searchbar/tokens.ts | 41 + src/components/Searchbar/utils.ts | 51 + .../Appbar/__snapshots__/Appbar.test.tsx.snap | 406 ++--- src/components/__tests__/Searchbar.test.tsx | 32 +- .../__snapshots__/Searchbar.test.tsx.snap | 1310 +++++++++-------- src/index.tsx | 3 +- 22 files changed, 1856 insertions(+), 1306 deletions(-) rename docs/5.x/docs/components/{ => Searchbar}/Searchbar.mdx (100%) create mode 100644 docs/5.x/docs/components/Searchbar/_meta.json rename docs/6.x/docs/components/{ => Searchbar}/Searchbar.mdx (53%) create mode 100644 docs/6.x/docs/components/Searchbar/SearchbarResults.mdx create mode 100644 docs/6.x/docs/components/Searchbar/_meta.json 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 diff --git a/docs/5.x/docs/components/Searchbar.mdx b/docs/5.x/docs/components/Searchbar/Searchbar.mdx similarity index 100% rename from docs/5.x/docs/components/Searchbar.mdx rename to docs/5.x/docs/components/Searchbar/Searchbar.mdx diff --git a/docs/5.x/docs/components/Searchbar/_meta.json b/docs/5.x/docs/components/Searchbar/_meta.json new file mode 100644 index 0000000000..24a691bcc1 --- /dev/null +++ b/docs/5.x/docs/components/Searchbar/_meta.json @@ -0,0 +1,3 @@ +[ + "Searchbar" +] diff --git a/docs/5.x/docs/components/_meta.json b/docs/5.x/docs/components/_meta.json index 548d39075a..c7cb734b0d 100644 --- a/docs/5.x/docs/components/_meta.json +++ b/docs/5.x/docs/components/_meta.json @@ -118,7 +118,13 @@ "collapsible": true, "collapsed": false }, - "Searchbar", + { + "type": "dir", + "name": "Searchbar", + "label": "Searchbar", + "collapsible": true, + "collapsed": false + }, { "type": "dir", "name": "SegmentedButtons", diff --git a/docs/6.x/docs/components/Searchbar.mdx b/docs/6.x/docs/components/Searchbar/Searchbar.mdx similarity index 53% rename from docs/6.x/docs/components/Searchbar.mdx rename to docs/6.x/docs/components/Searchbar/Searchbar.mdx index 76e475256f..c55022ae84 100644 --- a/docs/6.x/docs/components/Searchbar.mdx +++ b/docs/6.x/docs/components/Searchbar/Searchbar.mdx @@ -13,7 +13,7 @@ Searchbar is a simple input box where users can type search queries. - + ## Usage @@ -49,7 +49,7 @@ export default MyComponent; - +
@@ -57,7 +57,7 @@ export default MyComponent;
- +
@@ -65,7 +65,7 @@ export default MyComponent;
- +
@@ -73,7 +73,7 @@ export default MyComponent;
- +
@@ -81,7 +81,7 @@ export default MyComponent;
- +
@@ -89,7 +89,7 @@ export default MyComponent;
- +
@@ -97,7 +97,7 @@ export default MyComponent;
- +
@@ -105,7 +105,7 @@ export default MyComponent;
- +
@@ -113,7 +113,7 @@ export default MyComponent;
- +
@@ -121,7 +121,7 @@ export default MyComponent;
- +
@@ -129,7 +129,7 @@ export default MyComponent;
- +
@@ -137,7 +137,7 @@ export default MyComponent;
- +
@@ -145,7 +145,7 @@ export default MyComponent;
- +
@@ -153,7 +153,7 @@ export default MyComponent;
- +
@@ -161,7 +161,7 @@ export default MyComponent;
- +
@@ -169,7 +169,7 @@ export default MyComponent;
- +
@@ -177,7 +177,7 @@ export default MyComponent;
- +
@@ -185,7 +185,7 @@ export default MyComponent;
- +
@@ -193,7 +193,7 @@ export default MyComponent;
- +
@@ -201,7 +201,7 @@ export default MyComponent;
- +
@@ -209,7 +209,7 @@ export default MyComponent;
- +
@@ -217,7 +217,7 @@ export default MyComponent;
- +
@@ -225,7 +225,7 @@ export default MyComponent;
- +
@@ -233,7 +233,7 @@ export default MyComponent;
- + @@ -242,7 +242,7 @@ export default MyComponent; ## Theme colors - + diff --git a/docs/6.x/docs/components/Searchbar/SearchbarResults.mdx b/docs/6.x/docs/components/Searchbar/SearchbarResults.mdx new file mode 100644 index 0000000000..565500b758 --- /dev/null +++ b/docs/6.x/docs/components/Searchbar/SearchbarResults.mdx @@ -0,0 +1,93 @@ +--- +title: SearchbarResults +--- + +import PropTable from '@docs/components/PropTable.tsx'; +import ExtendsLink from '@docs/components/ExtendsLink.tsx'; +import ThemeColorsTable from '@docs/components/ThemeColorsTable.tsx'; +import ScreenshotTabs from '@docs/components/ScreenshotTabs.tsx'; +import ExtendedExample from '@docs/components/ExtendedExample.tsx'; + +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; +``` + + + ## Props + + + + +
+ +### children (required) + +
+ + + +
+ +### elevation + +
+ + + +
+ +### style + +
+ + + +
+ +### theme + +
+ + + +
+ +### testID + +
+ + + + + + + + + + diff --git a/docs/6.x/docs/components/Searchbar/_meta.json b/docs/6.x/docs/components/Searchbar/_meta.json new file mode 100644 index 0000000000..743cc5c9a2 --- /dev/null +++ b/docs/6.x/docs/components/Searchbar/_meta.json @@ -0,0 +1,4 @@ +[ + "Searchbar", + "SearchbarResults" +] diff --git a/docs/6.x/docs/components/_meta.json b/docs/6.x/docs/components/_meta.json index 6f793d41a3..1137b098d2 100644 --- a/docs/6.x/docs/components/_meta.json +++ b/docs/6.x/docs/components/_meta.json @@ -118,7 +118,13 @@ "collapsible": true, "collapsed": false }, - "Searchbar", + { + "type": "dir", + "name": "Searchbar", + "label": "Searchbar", + "collapsible": true, + "collapsed": false + }, { "type": "dir", "name": "SegmentedButtons", diff --git a/docs/component-docs.config.ts b/docs/component-docs.config.ts index bad25d4eec..74c3c3f569 100644 --- a/docs/component-docs.config.ts +++ b/docs/component-docs.config.ts @@ -115,7 +115,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/componentDocs6x.json b/docs/src/data/componentDocs6x.json index 56b356912b..891a48f72d 100644 --- a/docs/src/data/componentDocs6x.json +++ b/docs/src/data/componentDocs6x.json @@ -10274,8 +10274,8 @@ ], "group": "RadioButton" }, - "Searchbar": { - "filepath": "Searchbar.tsx", + "Searchbar/Searchbar": { + "filepath": "Searchbar/Searchbar.tsx", "title": "Searchbar", "description": "Searchbar is a simple input box where users can type search queries.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { Searchbar } from 'react-native-paper';\n\nconst MyComponent = () => {\n const [searchQuery, setSearchQuery] = React.useState('');\n\n return (\n \n );\n};\n\nexport default MyComponent;\n\n```", "link": "searchbar", @@ -10325,21 +10325,21 @@ "required": false, "tsType": { "name": "union", - "raw": "'bar' | 'view'", + "raw": "'contained' | 'divided'", "elements": [ { "name": "literal", - "value": "'bar'" + "value": "'contained'" }, { "name": "literal", - "value": "'view'" + "value": "'divided'" } ] }, - "description": "@supported Available in v5.x with theme version 3\nSearch layout mode, the default value is \"bar\".", + "description": "@supported Available in v5.x with theme version 3\nSearch layout mode, the default value is \"contained\".\n- `contained` - the recommended M3 Expressive style: a rounded, elevated\n bar whose horizontal margins shrink on focus (grow-wider effect).\n- `divided` - a full-bleed search view with square corners and a bottom\n `Divider`. Deprecated in M3 Expressive in favor of `contained`.", "defaultValue": { - "value": "'bar'", + "value": "'contained'", "computed": false } }, @@ -10435,7 +10435,7 @@ "tsType": { "name": "IconSource" }, - "description": "@supported Available in v5.x with theme version 3\nIcon name for the right trailering icon button.\nWorks only when `mode` is set to \"bar\". It won't be displayed if `loading` is set to `true`." + "description": "@supported Available in v5.x with theme version 3\nIcon name for the right trailering icon button.\nWorks only when `mode` is set to \"contained\". It won't be displayed if `loading` is set to `true`." }, "traileringIconColor": { "required": false, @@ -10521,14 +10521,14 @@ } } }, - "description": "@supported Available in v5.x with theme version 3\nCallback which returns a React element to display on the right side.\nWorks only when `mode` is set to \"bar\"." + "description": "@supported Available in v5.x with theme version 3\nCallback which returns a React element to display on the right side.\nWorks only when `mode` is set to \"contained\"." }, "showDivider": { "required": false, "tsType": { "name": "boolean" }, - "description": "@supported Available in v5.x with theme version 3\nWhether to show `Divider` at the bottom of the search.\nWorks only when `mode` is set to \"view\". True by default.", + "description": "@supported Available in v5.x with theme version 3\nWhether to show `Divider` at the bottom of the search.\nWorks only when `mode` is set to \"divided\". True by default.", "defaultValue": { "value": "true", "computed": false @@ -10689,7 +10689,105 @@ }, "type": "component", "dependencies": [ - "src/components/Searchbar.tsx" + "src/components/Searchbar/Searchbar.tsx" + ] + }, + "Searchbar/SearchbarResults": { + "filepath": "Searchbar/SearchbarResults.tsx", + "title": "SearchbarResults", + "description": "A container for the search results / suggestions list shown below a\n`Searchbar` (MD3 search anatomy element 6). It only provides the surface;\ngrouping results with gaps is left to the consumer.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { Searchbar, List } from 'react-native-paper';\n\nconst MyComponent = () => {\n const [query, setQuery] = React.useState('');\n\n return (\n <>\n \n \n \n \n \n \n );\n};\n\nexport default MyComponent;\n```", + "link": "searchbar-results", + "data": { + "description": "A container for the search results / suggestions list shown below a\n`Searchbar` (MD3 search anatomy element 6). It only provides the surface;\ngrouping results with gaps is left to the consumer.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { Searchbar, List } from 'react-native-paper';\n\nconst MyComponent = () => {\n const [query, setQuery] = React.useState('');\n\n return (\n <>\n \n \n \n \n \n \n );\n};\n\nexport default MyComponent;\n```", + "displayName": "SearchbarResults", + "methods": [], + "statics": [], + "props": { + "children": { + "required": true, + "tsType": { + "name": "ReactReactNode", + "raw": "React.ReactNode" + }, + "description": "Search results / suggestions to render inside the container." + }, + "elevation": { + "required": false, + "tsType": { + "name": "union", + "raw": "0 | 1 | 2 | 3 | 4 | 5 | Animated.Value", + "elements": [ + { + "name": "literal", + "value": "0" + }, + { + "name": "literal", + "value": "1" + }, + { + "name": "literal", + "value": "2" + }, + { + "name": "literal", + "value": "3" + }, + { + "name": "literal", + "value": "4" + }, + { + "name": "literal", + "value": "5" + }, + { + "name": "Animated.Value" + } + ] + }, + "description": "Changes the container's shadow and background.", + "defaultValue": { + "value": "0", + "computed": false + } + }, + "style": { + "required": false, + "tsType": { + "name": "StyleProp", + "elements": [ + { + "name": "ViewStyle" + } + ], + "raw": "StyleProp" + }, + "description": "" + }, + "theme": { + "required": false, + "tsType": { + "name": "ThemeProp" + }, + "description": "" + }, + "testID": { + "required": false, + "tsType": { + "name": "string" + }, + "description": "TestID used for testing purposes", + "defaultValue": { + "value": "'search-bar-results'", + "computed": false + } + } + } + }, + "type": "component", + "dependencies": [ + "src/components/Searchbar/SearchbarResults.tsx" ] }, "SegmentedButtons/SegmentedButtons": { diff --git a/docs/src/data/screenshots.ts b/docs/src/data/screenshots.ts index 92bf8f2788..3d51ec3821 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 20f16962f7..8a88292af8 100644 --- a/docs/src/data/themeColors.ts +++ b/docs/src/data/themeColors.ts @@ -272,9 +272,9 @@ 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', trailingIconColor: 'theme.colors.onSurfaceVariant', diff --git a/example/src/Examples/SearchbarExample.tsx b/example/src/Examples/SearchbarExample.tsx index 739002156b..56940b28b4 100644 --- a/example/src/Examples/SearchbarExample.tsx +++ b/example/src/Examples/SearchbarExample.tsx @@ -28,7 +28,7 @@ const SearchExample = () => { loadingViewMode: '', clickableBack: '', clickableDrawer: '', - clickableLoading: '', + withResults: '', }); const { colors } = useTheme(); @@ -36,7 +36,7 @@ const SearchExample = () => { return ( <> - + @@ -44,7 +44,7 @@ const SearchExample = () => { } value={searchQueries.searchBarMode} style={styles.searchbar} - mode="bar" + mode="contained" /> { traileringIconAccessibilityLabel={'microphone button'} onTraileringIconPress={() => setIsVisible(true)} style={styles.searchbar} - mode="bar" + mode="contained" /> setSearchQuery({ @@ -87,7 +87,7 @@ const SearchExample = () => { style={styles.searchbar} /> setSearchQuery({ @@ -115,12 +115,12 @@ const SearchExample = () => { } value={searchQueries.loadingBarMode} style={styles.searchbar} - mode="bar" + mode="contained" loading 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} 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; - /** - * 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; - /** - * @supported Available in v5.x with theme version 3 - * Icon name for the right trailering icon button. - * Works only when `mode` is set to "bar". It won't be displayed if `loading` is set to `true`. - */ - traileringIcon?: IconSource; - /** - * @supported Available in v5.x with theme version 3 - * 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; - /** - * @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?: 0 | 1 | 2 | 3 | 4 | 5 | Animated.Value; - /** - * Set style of the TextInput component inside the searchbar - */ - inputStyle?: StyleProp; - style?: Animated.WithAnimatedValue>; - /** - * 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 = 'bar', - showDivider = true, - inputStyle, - placeholder, - elevation = 0, - style, - theme: themeOverrides, - value, - loading = false, - testID = 'search-bar', - ref, - ...rest -}: Props) => { - const theme = useInternalTheme(themeOverrides); - const { direction } = useLocale(); - const { colors, fonts } = theme as 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 shouldRenderTraileringIcon = - isBarMode && traileringIcon && !loading && (!value || right !== undefined); - - return ( - - ( - - )) - } - theme={theme} - aria-label={searchAccessibilityLabel} - testID={`${testID}-icon`} - /> - - {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}-clear-icon`} - role="button" - theme={theme} - /> - - )} - {shouldRenderTraileringIcon ? ( - - ) : 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..169662d42c --- /dev/null +++ b/src/components/Searchbar/Searchbar.tsx @@ -0,0 +1,464 @@ +import * as React from 'react'; +import { + Animated, + Easing, + Platform, + StyleSheet, + TextInput, + View, +} from 'react-native'; +import type { + ColorValue, + GestureResponderEvent, + StyleProp, + TextInputProps, + TextStyle, + ViewStyle, +} from 'react-native'; + +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 { resolveCornerRadius } from '../../theme/utils/shape'; +import type { Theme, ThemeProp } from '../../types'; +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'; + +interface Style { + marginRight: number; +} + +export type Props = TextInputProps & { + /** + * 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 "contained". + * - `contained` - the recommended M3 Expressive style: a rounded, elevated + * bar whose horizontal margins shrink on focus (grow-wider effect). + * - `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; + /** + * @supported Available in v5.x with theme version 3 + * 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; + /** + * @supported Available in v5.x with theme version 3 + * 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; + /** + * @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 "contained". + */ + 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 "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?: 0 | 1 | 2 | 3 | 4 | 5 | Animated.Value; + /** + * Set style of the TextInput component inside the searchbar + */ + inputStyle?: StyleProp; + style?: Animated.WithAnimatedValue>; + /** + * 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 = 'search-bar', + onFocus, + onBlur, + ref, + ...rest +}: Props) => { + const theme = useInternalTheme(themeOverrides); + const { direction } = useLocale(); + const reduceMotion = useReduceMotion(); + const { colors } = theme as 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 { + inputColor, + placeholderColor, + leadingIconColor, + trailingIconColor, + cursorColor, + dividerColor, + } = getSearchbarColors(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 + ); + + // M3 Expressive focus transition: the contained bar grows wider on focus as + // its horizontal margin shrinks from `marginUnfocused` to `marginFocused`. + // `marginHorizontal` is a layout prop, so it must be animated on the JS + // driver (`useNativeDriver: false`) for the relayout to actually happen. + const marginAnim = React.useRef( + new Animated.Value(SearchbarTokens.marginUnfocused) + ).current; + + const animateMargin = (toValue: number) => { + if (reduceMotion) { + marginAnim.setValue(toValue); + return; + } + Animated.timing(marginAnim, { + toValue, + duration: theme.motion.duration.short4, + easing: Easing.bezier(...theme.motion.easing.standard), + useNativeDriver: false, + }).start(); + }; + + const handleFocus: NonNullable = (e) => { + if (isContained) { + animateMargin(SearchbarTokens.marginFocused); + } + onFocus?.(e); + }; + + const handleBlur: NonNullable = (e) => { + if (isContained) { + animateMargin(SearchbarTokens.marginUnfocused); + } + onBlur?.(e); + }; + + return ( + + + ( + + )) + } + theme={theme} + aria-label={searchAccessibilityLabel} + testID={`${testID}-icon`} + /> + + {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}-clear-icon`} + role="button" + theme={theme} + /> + + )} + {shouldRenderTraileringIcon ? ( + + ) : null} + {isContained && + right?.({ color: inputColor, style: styles.rightStyle, testID })} + {!isContained && showDivider && ( + + )} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + }, + input: { + flex: 1, + fontSize: 18, + 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..b90f84df38 --- /dev/null +++ b/src/components/Searchbar/SearchbarResults.tsx @@ -0,0 +1,90 @@ +import * as React from 'react'; +import { Animated, StyleSheet } from 'react-native'; +import type { StyleProp, ViewStyle } from 'react-native'; + +import { getSearchbarColors } from './utils'; +import { useInternalTheme } from '../../core/theming'; +import type { ThemeProp } from '../../types'; +import Surface from '../Surface'; + +export type Props = { + /** + * Search results / suggestions to render inside the container. + */ + children: React.ReactNode; + /** + * Changes the container's shadow and background. + */ + elevation?: 0 | 1 | 2 | 3 | 4 | 5 | Animated.Value; + 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 = 'search-bar-results', +}: Props) => { + const theme = useInternalTheme(themeOverrides); + const { resultsContainerColor } = getSearchbarColors(theme); + + 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..46acdc5eda --- /dev/null +++ b/src/components/Searchbar/tokens.ts @@ -0,0 +1,41 @@ +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', +} 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: 'onSurfaceVariant', + 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..b03b3186a5 --- /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 '../../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 a5d9d95766..68da870246 100644 --- a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap +++ b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap @@ -46,28 +46,17 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` collapsable={false} style={ { - "backgroundColor": "rgba(236, 230, 240, 1)", - "borderRadius": 28, - "shadowColor": "rgba(0, 0, 0, 1)", - "shadowOffset": { - "height": 0, - "width": 0, - }, - "shadowOpacity": 0, - "shadowRadius": 0, + "marginHorizontal": 24, } } - testID="search-bar-container-outer-layer" + testID="search-bar-container-wrapper" > - - - - - magnify - - - - - - - - close + magnify + + + + + + + + close + + + + + + diff --git a/src/components/__tests__/Searchbar.test.tsx b/src/components/__tests__/Searchbar.test.tsx index 0671263277..b9cb31639a 100644 --- a/src/components/__tests__/Searchbar.test.tsx +++ b/src/components/__tests__/Searchbar.test.tsx @@ -136,13 +136,13 @@ it('renders clear icon wrapper, with appropriate style for v3', async () => { ).toHaveStyle({ display: 'none' }); }); -it('renders trailering icon when mode is set to "bar"', async () => { +it('renders trailering icon when mode is set to "contained"', async () => { await render( ); @@ -158,7 +158,7 @@ it('renders trailering icon with press functionality', async () => { value={''} traileringIcon={'microphone'} onTraileringIconPress={onTraileringIconPressMock} - mode="bar" + mode="contained" /> ); @@ -172,7 +172,7 @@ it('renders clear icon instead of trailering icon', async () => { testID="search-bar" value={''} traileringIcon={'microphone'} - mode="bar" + mode="contained" /> ); @@ -183,7 +183,7 @@ it('renders clear icon instead of trailering icon', async () => { testID="search-bar" value={'test'} traileringIcon={'microphone'} - mode="bar" + mode="contained" /> ); @@ -193,10 +193,28 @@ it('renders clear icon instead of trailering icon', async () => { expect(screen.getByTestId('search-bar-icon-wrapper')).toBeOnTheScreen(); }); -it('renders searchbar in "view" mode', async () => { - await render(); +it('renders searchbar in "divided" mode', async () => { + await render(); 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-container-wrapper')).toHaveStyle({ + marginHorizontal: 24, + }); +}); + +it('renders a results container via Searchbar.Results', async () => { + await render( + + + + ); + + expect(screen.getByTestId('search-bar-results')).toBeOnTheScreen(); +}); diff --git a/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap b/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap index 29b660cbd9..36eb17ac65 100644 --- a/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap @@ -5,28 +5,17 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "backgroundColor": "rgba(236, 230, 240, 1)", - "borderRadius": 28, - "shadowColor": "rgba(0, 0, 0, 1)", - "shadowOffset": { - "height": 0, - "width": 0, - }, - "shadowOpacity": 0, - "shadowRadius": 0, + "marginHorizontal": 24, } } - testID="search-bar-container-outer-layer" + testID="search-bar-container-wrapper" > - + + - magnify - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + magnify + + - - - + @@ -259,13 +256,13 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "height": 24, - "transform": [ - { - "rotate": "45deg", - }, - ], - "width": 24, + "alignItems": "center", + "bottom": 0, + "justifyContent": "center", + "left": 0, + "position": "absolute", + "right": 0, + "top": 0, } } > @@ -273,8 +270,12 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "height": 12, - "overflow": "hidden", + "height": 24, + "transform": [ + { + "rotate": "45deg", + }, + ], "width": 24, } } @@ -283,15 +284,8 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "height": 24, - "transform": [ - { - "translateY": 0, - }, - { - "rotate": "-165deg", - }, - ], + "height": 12, + "overflow": "hidden", "width": 24, } } @@ -300,8 +294,15 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "height": 12, - "overflow": "hidden", + "height": 24, + "transform": [ + { + "translateY": 0, + }, + { + "rotate": "-165deg", + }, + ], "width": 24, } } @@ -310,44 +311,40 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "borderColor": "rgba(103, 80, 164, 1)", - "borderRadius": 12, - "borderWidth": 2.4, - "height": 24, + "height": 12, + "overflow": "hidden", "width": 24, } } - /> + > + + - - @@ -355,9 +352,12 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "height": 12, - "overflow": "hidden", - "top": 12, + "height": 24, + "transform": [ + { + "rotate": "45deg", + }, + ], "width": 24, } } @@ -366,15 +366,9 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "height": 24, - "transform": [ - { - "translateY": -12, - }, - { - "rotate": "345deg", - }, - ], + "height": 12, + "overflow": "hidden", + "top": 12, "width": 24, } } @@ -383,8 +377,15 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "height": 12, - "overflow": "hidden", + "height": 24, + "transform": [ + { + "translateY": -12, + }, + { + "rotate": "345deg", + }, + ], "width": 24, } } @@ -393,14 +394,25 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "borderColor": "rgba(103, 80, 164, 1)", - "borderRadius": 12, - "borderWidth": 2.4, - "height": 24, + "height": 12, + "overflow": "hidden", "width": 24, } } - /> + > + + @@ -417,28 +429,17 @@ exports[`renders with placeholder 1`] = ` collapsable={false} style={ { - "backgroundColor": "rgba(236, 230, 240, 1)", - "borderRadius": 28, - "shadowColor": "rgba(0, 0, 0, 1)", - "shadowOffset": { - "height": 0, - "width": 0, - }, - "shadowOpacity": 0, - "shadowRadius": 0, + "marginHorizontal": 24, } } - testID="search-bar-container-outer-layer" + testID="search-bar-container-wrapper" > - - - - - - magnify - - - - - - + } + testID="search-bar-container-outer-layer" + > - close + magnify + + + + + + + + close + + + + + + @@ -790,28 +814,17 @@ exports[`renders with text 1`] = ` collapsable={false} style={ { - "backgroundColor": "rgba(236, 230, 240, 1)", - "borderRadius": 28, - "shadowColor": "rgba(0, 0, 0, 1)", - "shadowOffset": { - "height": 0, - "width": 0, - }, - "shadowOpacity": 0, - "shadowRadius": 0, + "marginHorizontal": 24, } } - testID="search-bar-container-outer-layer" + testID="search-bar-container-wrapper" > - - - - - magnify - - - - - - - - close + magnify + + + + + + + + close + + + + + + diff --git a/src/index.tsx b/src/index.tsx index 8863e2fa20..c77426fe3f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -124,7 +124,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'; From a7ceb092285ffddfaed730f0f3824f391ab1ef21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Muzyk?= Date: Wed, 8 Jul 2026 15:33:20 +0200 Subject: [PATCH 2/5] fix: first set of corrections --- .../components/{Searchbar => }/Searchbar.mdx | 0 docs/5.x/docs/components/Searchbar/_meta.json | 3 - docs/5.x/docs/components/_meta.json | 8 +- src/components/Searchbar/Searchbar.tsx | 231 ++- .../Appbar/__snapshots__/Appbar.test.tsx.snap | 407 +++-- src/components/__tests__/Searchbar.test.tsx | 2 +- .../__snapshots__/Searchbar.test.tsx.snap | 1313 ++++++++--------- 7 files changed, 958 insertions(+), 1006 deletions(-) rename docs/5.x/docs/components/{Searchbar => }/Searchbar.mdx (100%) delete mode 100644 docs/5.x/docs/components/Searchbar/_meta.json diff --git a/docs/5.x/docs/components/Searchbar/Searchbar.mdx b/docs/5.x/docs/components/Searchbar.mdx similarity index 100% rename from docs/5.x/docs/components/Searchbar/Searchbar.mdx rename to docs/5.x/docs/components/Searchbar.mdx diff --git a/docs/5.x/docs/components/Searchbar/_meta.json b/docs/5.x/docs/components/Searchbar/_meta.json deleted file mode 100644 index 24a691bcc1..0000000000 --- a/docs/5.x/docs/components/Searchbar/_meta.json +++ /dev/null @@ -1,3 +0,0 @@ -[ - "Searchbar" -] diff --git a/docs/5.x/docs/components/_meta.json b/docs/5.x/docs/components/_meta.json index c7cb734b0d..548d39075a 100644 --- a/docs/5.x/docs/components/_meta.json +++ b/docs/5.x/docs/components/_meta.json @@ -118,13 +118,7 @@ "collapsible": true, "collapsed": false }, - { - "type": "dir", - "name": "Searchbar", - "label": "Searchbar", - "collapsible": true, - "collapsed": false - }, + "Searchbar", { "type": "dir", "name": "SegmentedButtons", diff --git a/src/components/Searchbar/Searchbar.tsx b/src/components/Searchbar/Searchbar.tsx index 169662d42c..c7c64e7628 100644 --- a/src/components/Searchbar/Searchbar.tsx +++ b/src/components/Searchbar/Searchbar.tsx @@ -22,7 +22,7 @@ import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import { resolveCornerRadius } from '../../theme/utils/shape'; -import type { Theme, ThemeProp } from '../../types'; +import type { ThemeProp } from '../../types'; import ActivityIndicator from '../ActivityIndicator'; import Divider from '../Divider'; import type { IconSource } from '../Icon'; @@ -206,7 +206,6 @@ const Searchbar = ({ const theme = useInternalTheme(themeOverrides); const { direction } = useLocale(); const reduceMotion = useReduceMotion(); - const { colors } = theme as Theme; const root = React.useRef(null); React.useImperativeHandle(ref, () => ({ @@ -229,6 +228,7 @@ const Searchbar = ({ const { dark } = theme; const { + containerColor, inputColor, placeholderColor, leadingIconColor, @@ -289,134 +289,131 @@ const Searchbar = ({ }; return ( - - ( + + )) + } + theme={theme} + aria-label={searchAccessibilityLabel} + testID={`${testID}-icon`} + /> + + placeholder={placeholder || ''} + placeholderTextColor={placeholderColor} + selectionColor={cursorColor} + underlineColorAndroid="transparent" + returnKeyType="search" + keyboardAppearance={dark ? 'dark' : 'light'} + role="searchbox" + ref={root} + value={value} + testID={testID} + onFocus={handleFocus} + onBlur={handleBlur} + {...rest} + /> + {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}-clear-icon`} + role="button" + theme={theme} + /> + + )} + {shouldRenderTraileringIcon ? ( ( - - )) - } + onPress={onTraileringIconPress} + iconColor={traileringIconColor || trailingIconColor} + icon={traileringIcon} + aria-label={traileringIconAccessibilityLabel} theme={theme} - aria-label={searchAccessibilityLabel} - testID={`${testID}-icon`} + testID={`${testID}-trailering-icon`} /> - - {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}-clear-icon`} - role="button" - theme={theme} - /> - - )} - {shouldRenderTraileringIcon ? ( - - ) : null} - {isContained && - right?.({ color: inputColor, style: styles.rightStyle, testID })} - {!isContained && showDivider && ( - - )} - - + )} + ); }; diff --git a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap index 68da870246..354381b40f 100644 --- a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap +++ b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap @@ -46,17 +46,29 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` collapsable={false} style={ { + "backgroundColor": "rgba(236, 230, 240, 1)", + "borderRadius": 28, "marginHorizontal": 24, + "shadowColor": "rgba(0, 0, 0, 1)", + "shadowOffset": { + "height": 0, + "width": 0, + }, + "shadowOpacity": 0, + "shadowRadius": 0, } } - testID="search-bar-container-wrapper" + testID="search-bar-container-outer-layer" > + + + + + magnify + + + + + + + - magnify + close - - - - - - - - close - - - - - - diff --git a/src/components/__tests__/Searchbar.test.tsx b/src/components/__tests__/Searchbar.test.tsx index b9cb31639a..fa46092d77 100644 --- a/src/components/__tests__/Searchbar.test.tsx +++ b/src/components/__tests__/Searchbar.test.tsx @@ -204,7 +204,7 @@ it('renders searchbar in "divided" mode', async () => { it('applies the unfocused container margin in "contained" mode', async () => { await render(); - expect(screen.getByTestId('search-bar-container-wrapper')).toHaveStyle({ + expect(screen.getByTestId('search-bar-container-outer-layer')).toHaveStyle({ marginHorizontal: 24, }); }); diff --git a/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap b/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap index 36eb17ac65..4803c90dd5 100644 --- a/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap @@ -5,17 +5,29 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { + "backgroundColor": "rgba(236, 230, 240, 1)", + "borderRadius": 28, "marginHorizontal": 24, + "shadowColor": "rgba(0, 0, 0, 1)", + "shadowOffset": { + "height": 0, + "width": 0, + }, + "shadowOpacity": 0, + "shadowRadius": 0, } } - testID="search-bar-container-wrapper" + testID="search-bar-container-outer-layer" > - + - - - magnify - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + magnify + - + + + @@ -256,13 +262,13 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "alignItems": "center", - "bottom": 0, - "justifyContent": "center", - "left": 0, - "position": "absolute", - "right": 0, - "top": 0, + "height": 24, + "transform": [ + { + "rotate": "45deg", + }, + ], + "width": 24, } } > @@ -270,12 +276,8 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "height": 24, - "transform": [ - { - "rotate": "45deg", - }, - ], + "height": 12, + "overflow": "hidden", "width": 24, } } @@ -284,8 +286,15 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "height": 12, - "overflow": "hidden", + "height": 24, + "transform": [ + { + "translateY": 0, + }, + { + "rotate": "-165deg", + }, + ], "width": 24, } } @@ -294,15 +303,8 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "height": 24, - "transform": [ - { - "translateY": 0, - }, - { - "rotate": "-165deg", - }, - ], + "height": 12, + "overflow": "hidden", "width": 24, } } @@ -311,40 +313,44 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "height": 12, - "overflow": "hidden", + "borderColor": "rgba(103, 80, 164, 1)", + "borderRadius": 12, + "borderWidth": 2.4, + "height": 24, "width": 24, } } - > - - + /> + + @@ -352,12 +358,9 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "height": 24, - "transform": [ - { - "rotate": "45deg", - }, - ], + "height": 12, + "overflow": "hidden", + "top": 12, "width": 24, } } @@ -366,9 +369,15 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "height": 12, - "overflow": "hidden", - "top": 12, + "height": 24, + "transform": [ + { + "translateY": -12, + }, + { + "rotate": "345deg", + }, + ], "width": 24, } } @@ -377,15 +386,8 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "height": 24, - "transform": [ - { - "translateY": -12, - }, - { - "rotate": "345deg", - }, - ], + "height": 12, + "overflow": "hidden", "width": 24, } } @@ -394,25 +396,14 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "height": 12, - "overflow": "hidden", + "borderColor": "rgba(103, 80, 164, 1)", + "borderRadius": 12, + "borderWidth": 2.4, + "height": 24, "width": 24, } } - > - - + /> @@ -429,17 +420,29 @@ exports[`renders with placeholder 1`] = ` collapsable={false} style={ { + "backgroundColor": "rgba(236, 230, 240, 1)", + "borderRadius": 28, "marginHorizontal": 24, + "shadowColor": "rgba(0, 0, 0, 1)", + "shadowOffset": { + "height": 0, + "width": 0, + }, + "shadowOpacity": 0, + "shadowRadius": 0, } } - testID="search-bar-container-wrapper" + testID="search-bar-container-outer-layer" > + } + testID="search-bar-container" + > + + + + + + magnify + + + + + + - magnify + close - - - - - - - - close - - - - - - @@ -814,17 +796,29 @@ exports[`renders with text 1`] = ` collapsable={false} style={ { + "backgroundColor": "rgba(236, 230, 240, 1)", + "borderRadius": 28, "marginHorizontal": 24, + "shadowColor": "rgba(0, 0, 0, 1)", + "shadowOffset": { + "height": 0, + "width": 0, + }, + "shadowOpacity": 0, + "shadowRadius": 0, } } - testID="search-bar-container-wrapper" + testID="search-bar-container-outer-layer" > + + + + + magnify + + + + + + + - magnify + close - - - - - - - - close - - - - - - From 8bc9224c695b1e28d5a4d583d8a97448986452d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Muzyk?= Date: Wed, 8 Jul 2026 16:04:39 +0200 Subject: [PATCH 3/5] fix: docs --- docs/5.x/docs/components/{ => Searchbar}/Searchbar.mdx | 0 docs/5.x/docs/components/Searchbar/_meta.json | 3 +++ docs/5.x/docs/components/_meta.json | 8 +++++++- 3 files changed, 10 insertions(+), 1 deletion(-) rename docs/5.x/docs/components/{ => Searchbar}/Searchbar.mdx (100%) create mode 100644 docs/5.x/docs/components/Searchbar/_meta.json diff --git a/docs/5.x/docs/components/Searchbar.mdx b/docs/5.x/docs/components/Searchbar/Searchbar.mdx similarity index 100% rename from docs/5.x/docs/components/Searchbar.mdx rename to docs/5.x/docs/components/Searchbar/Searchbar.mdx diff --git a/docs/5.x/docs/components/Searchbar/_meta.json b/docs/5.x/docs/components/Searchbar/_meta.json new file mode 100644 index 0000000000..24a691bcc1 --- /dev/null +++ b/docs/5.x/docs/components/Searchbar/_meta.json @@ -0,0 +1,3 @@ +[ + "Searchbar" +] diff --git a/docs/5.x/docs/components/_meta.json b/docs/5.x/docs/components/_meta.json index 548d39075a..c7cb734b0d 100644 --- a/docs/5.x/docs/components/_meta.json +++ b/docs/5.x/docs/components/_meta.json @@ -118,7 +118,13 @@ "collapsible": true, "collapsed": false }, - "Searchbar", + { + "type": "dir", + "name": "Searchbar", + "label": "Searchbar", + "collapsible": true, + "collapsed": false + }, { "type": "dir", "name": "SegmentedButtons", From b6615ba31122965c9c3c93ee5825932c0c770226 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Muzyk?= Date: Thu, 9 Jul 2026 10:34:14 +0200 Subject: [PATCH 4/5] fix: more corrections --- .../docs/components/Searchbar/Searchbar.mdx | 12 +- docs/src/data/componentDocs6x.json | 12 +- example/src/Examples/SearchbarExample.tsx | 2 +- src/components/Searchbar/Searchbar.tsx | 334 +++-- src/components/Searchbar/SearchbarResults.tsx | 5 +- .../Appbar/__snapshots__/Appbar.test.tsx.snap | 411 ++--- src/components/__tests__/Searchbar.test.tsx | 56 +- .../__tests__/SearchbarUtils.test.tsx | 30 + .../__snapshots__/Searchbar.test.tsx.snap | 1325 +++++++++-------- 9 files changed, 1168 insertions(+), 1019 deletions(-) create mode 100644 src/components/__tests__/SearchbarUtils.test.tsx diff --git a/docs/6.x/docs/components/Searchbar/Searchbar.mdx b/docs/6.x/docs/components/Searchbar/Searchbar.mdx index c55022ae84..3b9897d1f7 100644 --- a/docs/6.x/docs/components/Searchbar/Searchbar.mdx +++ b/docs/6.x/docs/components/Searchbar/Searchbar.mdx @@ -69,7 +69,7 @@ export default MyComponent;
-### mode Available in v5.x with theme version 3 +### mode
@@ -133,7 +133,7 @@ export default MyComponent;
-### traileringIcon Available in v5.x with theme version 3 +### traileringIcon
@@ -141,7 +141,7 @@ export default MyComponent;
-### traileringIconColor Available in v5.x with theme version 3 +### traileringIconColor
@@ -165,7 +165,7 @@ export default MyComponent;
-### right Available in v5.x with theme version 3 +### right
@@ -173,7 +173,7 @@ export default MyComponent;
-### showDivider Available in v5.x with theme version 3 +### showDivider
@@ -181,7 +181,7 @@ export default MyComponent;
-### elevation Available in v5.x with theme version 3 +### elevation
diff --git a/docs/src/data/componentDocs6x.json b/docs/src/data/componentDocs6x.json index 891a48f72d..528b830954 100644 --- a/docs/src/data/componentDocs6x.json +++ b/docs/src/data/componentDocs6x.json @@ -10337,7 +10337,7 @@ } ] }, - "description": "@supported Available in v5.x with theme version 3\nSearch layout mode, the default value is \"contained\".\n- `contained` - the recommended M3 Expressive style: a rounded, elevated\n bar whose horizontal margins shrink on focus (grow-wider effect).\n- `divided` - a full-bleed search view with square corners and a bottom\n `Divider`. Deprecated in M3 Expressive in favor of `contained`.", + "description": "Search layout mode, the default value is \"contained\".\n- `contained` - the recommended M3 Expressive style: a rounded, elevated\n bar whose horizontal margins animate from 24dp down to 12dp on focus\n (grow-wider effect). Providing any horizontal margin via `style`\n replaces the built-in margin and disables the focus transition.\n- `divided` - a full-bleed search view with square corners and a bottom\n `Divider`. Deprecated in M3 Expressive in favor of `contained`.", "defaultValue": { "value": "'contained'", "computed": false @@ -10435,14 +10435,14 @@ "tsType": { "name": "IconSource" }, - "description": "@supported Available in v5.x with theme version 3\nIcon name for the right trailering icon button.\nWorks only when `mode` is set to \"contained\". It won't be displayed if `loading` is set to `true`." + "description": "Icon name for the right trailering icon button.\nWorks only when `mode` is set to \"contained\". It won't be displayed if `loading` is set to `true`." }, "traileringIconColor": { "required": false, "tsType": { "name": "ColorValue" }, - "description": "@supported Available in v5.x with theme version 3\nCustom color for the right trailering icon, default will be derived from theme" + "description": "Custom color for the right trailering icon, default will be derived from theme" }, "onTraileringIconPress": { "required": false, @@ -10521,14 +10521,14 @@ } } }, - "description": "@supported Available in v5.x with theme version 3\nCallback which returns a React element to display on the right side.\nWorks only when `mode` is set to \"contained\"." + "description": "Callback which returns a React element to display on the right side.\nWorks only when `mode` is set to \"contained\"." }, "showDivider": { "required": false, "tsType": { "name": "boolean" }, - "description": "@supported Available in v5.x with theme version 3\nWhether to show `Divider` at the bottom of the search.\nWorks only when `mode` is set to \"divided\". True by default.", + "description": "Whether to show `Divider` at the bottom of the search.\nWorks only when `mode` is set to \"divided\". True by default.", "defaultValue": { "value": "true", "computed": false @@ -10569,7 +10569,7 @@ } ] }, - "description": "@supported Available in v5.x with theme version 3\nChanges Searchbar shadow and background on iOS and Android.", + "description": "Changes Searchbar shadow and background on iOS and Android.", "defaultValue": { "value": "0", "computed": false diff --git a/example/src/Examples/SearchbarExample.tsx b/example/src/Examples/SearchbarExample.tsx index 56940b28b4..aaba106293 100644 --- a/example/src/Examples/SearchbarExample.tsx +++ b/example/src/Examples/SearchbarExample.tsx @@ -237,7 +237,7 @@ SearchExample.title = 'Searchbar'; const styles = StyleSheet.create({ searchbar: { - margin: 4, + marginVertical: 4, }, }); diff --git a/src/components/Searchbar/Searchbar.tsx b/src/components/Searchbar/Searchbar.tsx index c7c64e7628..4322aab5b0 100644 --- a/src/components/Searchbar/Searchbar.tsx +++ b/src/components/Searchbar/Searchbar.tsx @@ -1,12 +1,5 @@ import * as React from 'react'; -import { - Animated, - Easing, - Platform, - StyleSheet, - TextInput, - View, -} from 'react-native'; +import { Animated, Platform, StyleSheet, TextInput, View } from 'react-native'; import type { ColorValue, GestureResponderEvent, @@ -16,11 +9,19 @@ import type { ViewStyle, } from 'react-native'; +import Reanimated, { + ReduceMotion, + useAnimatedStyle, + useSharedValue, + withSpring, +} 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 { resolveCornerRadius } from '../../theme/utils/shape'; import type { ThemeProp } from '../../types'; import ActivityIndicator from '../ActivityIndicator'; @@ -34,6 +35,15 @@ interface Style { marginRight: number; } +const HORIZONTAL_MARGIN_KEYS = [ + 'margin', + 'marginHorizontal', + 'marginLeft', + 'marginRight', + 'marginStart', + 'marginEnd', +] as const; + export type Props = TextInputProps & { /** * Hint text shown when the input is empty. @@ -48,10 +58,11 @@ export type Props = TextInputProps & { */ onChangeText?: (query: string) => void; /** - * @supported Available in v5.x with theme version 3 * Search layout mode, the default value is "contained". * - `contained` - the recommended M3 Expressive style: a rounded, elevated - * bar whose horizontal margins shrink on focus (grow-wider effect). + * 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`. */ @@ -87,13 +98,11 @@ export type Props = TextInputProps & { */ clearAccessibilityLabel?: string; /** - * @supported Available in v5.x with theme version 3 * 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; /** - * @supported Available in v5.x with theme version 3 * Custom color for the right trailering icon, default will be derived from theme */ traileringIconColor?: ColorValue; @@ -106,7 +115,6 @@ export type Props = TextInputProps & { */ traileringIconAccessibilityLabel?: 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 "contained". */ @@ -116,13 +124,11 @@ export type Props = TextInputProps & { 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 "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?: 0 | 1 | 2 | 3 | 4 | 5 | Animated.Value; @@ -235,7 +241,7 @@ const Searchbar = ({ trailingIconColor, cursorColor, dividerColor, - } = getSearchbarColors(theme); + } = React.useMemo(() => getSearchbarColors(theme), [theme]); const iconColor = customIconColor || leadingIconColor; const font = getSearchbarInputFont(theme); @@ -253,167 +259,193 @@ const Searchbar = ({ 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`. - // `marginHorizontal` is a layout prop, so it must be animated on the JS - // driver (`useNativeDriver: false`) for the relayout to actually happen. - const marginAnim = React.useRef( - new Animated.Value(SearchbarTokens.marginUnfocused) - ).current; + const focusMargin = useSharedValue(SearchbarTokens.marginUnfocused); - const animateMargin = (toValue: number) => { - if (reduceMotion) { - marginAnim.setValue(toValue); - return; - } - Animated.timing(marginAnim, { - toValue, - duration: theme.motion.duration.short4, - easing: Easing.bezier(...theme.motion.easing.standard), - useNativeDriver: false, - }).start(); - }; + 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) => { - if (isContained) { - animateMargin(SearchbarTokens.marginFocused); - } + focusMargin.value = withSpring(SearchbarTokens.marginFocused, growSpring); onFocus?.(e); }; const handleBlur: NonNullable = (e) => { - if (isContained) { - animateMargin(SearchbarTokens.marginUnfocused); - } + 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, + })); + + // A consumer-provided horizontal margin wins over the built-in one and + // disables the focus transition. + const flatStyle = StyleSheet.flatten(style as StyleProp); + const hasCustomHorizontalMargin = HORIZONTAL_MARGIN_KEYS.some( + (key) => flatStyle?.[key] !== undefined + ); + const applyFocusMargin = isContained && !hasCustomHorizontalMargin; + return ( - - ( - - )) - } - theme={theme} - aria-label={searchAccessibilityLabel} - testID={`${testID}-icon`} - /> - - {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}-clear-icon`} - role="button" - theme={theme} - /> - - )} - {shouldRenderTraileringIcon ? ( + testID={`${testID}-container`} + elevation={elevation} + container + theme={theme} + > ( + + )) + } theme={theme} - testID={`${testID}-trailering-icon`} + aria-label={searchAccessibilityLabel} + testID={`${testID}-icon`} /> - ) : null} - {isContained && - right?.({ color: inputColor, style: styles.rightStyle, testID })} - {!isContained && showDivider && ( - - )} - + {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}-clear-icon`} + role="button" + theme={theme} + /> + + )} + {shouldRenderTraileringIcon ? ( + + ) : null} + {isContained && + right?.({ + color: trailingIconColor, + style: styles.rightStyle, + testID, + })} + {!isContained && showDivider && ( + + )} + + ); }; diff --git a/src/components/Searchbar/SearchbarResults.tsx b/src/components/Searchbar/SearchbarResults.tsx index b90f84df38..453b843bd8 100644 --- a/src/components/Searchbar/SearchbarResults.tsx +++ b/src/components/Searchbar/SearchbarResults.tsx @@ -62,7 +62,10 @@ const SearchbarResults = ({ testID = 'search-bar-results', }: Props) => { const theme = useInternalTheme(themeOverrides); - const { resultsContainerColor } = getSearchbarColors(theme); + const { resultsContainerColor } = React.useMemo( + () => getSearchbarColors(theme), + [theme] + ); return ( - - - - - magnify - - - - - - - - close + magnify + + + + + + + + close + + + + + + diff --git a/src/components/__tests__/Searchbar.test.tsx b/src/components/__tests__/Searchbar.test.tsx index fa46092d77..854fc0f2ef 100644 --- a/src/components/__tests__/Searchbar.test.tsx +++ b/src/components/__tests__/Searchbar.test.tsx @@ -1,7 +1,7 @@ -import { Animated } from 'react-native'; +import { Animated, Text } from 'react-native'; import { expect, it, jest } from '@jest/globals'; -import { act, userEvent } from '@testing-library/react-native'; +import { act, fireEvent, userEvent } from '@testing-library/react-native'; import { render, screen } from '../../test-utils'; import * as Avatar from '../Avatar/Avatar'; @@ -204,15 +204,63 @@ it('renders searchbar in "divided" mode', async () => { it('applies the unfocused container margin in "contained" mode', async () => { await render(); + expect(screen.getByTestId('search-bar-wrapper')).toHaveStyle({ + marginLeft: 24, + marginRight: 24, + }); +}); + +it('does not apply the container margin in "divided" mode', async () => { + await render(); + + expect(screen.getByTestId('search-bar-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-wrapper')).not.toHaveStyle({ + marginLeft: 24, + marginRight: 24, + }); expect(screen.getByTestId('search-bar-container-outer-layer')).toHaveStyle({ - marginHorizontal: 24, + marginHorizontal: 0, }); }); +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 ); diff --git a/src/components/__tests__/SearchbarUtils.test.tsx b/src/components/__tests__/SearchbarUtils.test.tsx new file mode 100644 index 0000000000..5e06fdcae8 --- /dev/null +++ b/src/components/__tests__/SearchbarUtils.test.tsx @@ -0,0 +1,30 @@ +import { describe, expect, it } from '@jest/globals'; + +import { getTheme } from '../../core/theming'; +import { getSearchbarColors, getSearchbarInputFont } from '../Searchbar/utils'; + +describe('getSearchbarColors', () => { + it('resolves the MD3 color roles, with input/placeholder in spec order', () => { + const theme = getTheme(); + expect(getSearchbarColors(theme)).toEqual({ + containerColor: theme.colors.surfaceContainerHigh, + inputColor: theme.colors.onSurface, + placeholderColor: theme.colors.onSurfaceVariant, + leadingIconColor: theme.colors.onSurfaceVariant, + 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 = getTheme(); + 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 4803c90dd5..bb4e37cb0f 100644 --- a/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap @@ -2,32 +2,20 @@ exports[`activity indicator snapshot test 1`] = ` - + + - magnify - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + magnify + + - - - + @@ -262,13 +256,13 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "height": 24, - "transform": [ - { - "rotate": "45deg", - }, - ], - "width": 24, + "alignItems": "center", + "bottom": 0, + "justifyContent": "center", + "left": 0, + "position": "absolute", + "right": 0, + "top": 0, } } > @@ -276,8 +270,12 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "height": 12, - "overflow": "hidden", + "height": 24, + "transform": [ + { + "rotate": "45deg", + }, + ], "width": 24, } } @@ -286,15 +284,8 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "height": 24, - "transform": [ - { - "translateY": 0, - }, - { - "rotate": "-165deg", - }, - ], + "height": 12, + "overflow": "hidden", "width": 24, } } @@ -303,8 +294,15 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "height": 12, - "overflow": "hidden", + "height": 24, + "transform": [ + { + "translateY": 0, + }, + { + "rotate": "-165deg", + }, + ], "width": 24, } } @@ -313,44 +311,40 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "borderColor": "rgba(103, 80, 164, 1)", - "borderRadius": 12, - "borderWidth": 2.4, - "height": 24, + "height": 12, + "overflow": "hidden", "width": 24, } } - /> + > + + - - @@ -358,9 +352,12 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "height": 12, - "overflow": "hidden", - "top": 12, + "height": 24, + "transform": [ + { + "rotate": "45deg", + }, + ], "width": 24, } } @@ -369,15 +366,9 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "height": 24, - "transform": [ - { - "translateY": -12, - }, - { - "rotate": "345deg", - }, - ], + "height": 12, + "overflow": "hidden", + "top": 12, "width": 24, } } @@ -386,8 +377,15 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "height": 12, - "overflow": "hidden", + "height": 24, + "transform": [ + { + "translateY": -12, + }, + { + "rotate": "345deg", + }, + ], "width": 24, } } @@ -396,14 +394,25 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "borderColor": "rgba(103, 80, 164, 1)", - "borderRadius": 12, - "borderWidth": 2.4, - "height": 24, + "height": 12, + "overflow": "hidden", "width": 24, } } - /> + > + + @@ -417,32 +426,20 @@ exports[`activity indicator snapshot test 1`] = ` exports[`renders with placeholder 1`] = ` - - - - - - magnify - - - - - - + } + testID="search-bar-container-outer-layer" + > - close + magnify + + + + + + + + close + + + + + + @@ -793,32 +811,20 @@ exports[`renders with placeholder 1`] = ` exports[`renders with text 1`] = ` - - - - - magnify - - - - - - - - close + magnify + + + + + + + + close + + + + + + From 694f8e03e91ac5cba936e641519ac548bd872b0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Muzyk?= Date: Thu, 9 Jul 2026 10:51:28 +0200 Subject: [PATCH 5/5] fix: dead code removal --- src/components/Searchbar/Searchbar.tsx | 1 - .../__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap | 1 - src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap | 3 --- 3 files changed, 5 deletions(-) diff --git a/src/components/Searchbar/Searchbar.tsx b/src/components/Searchbar/Searchbar.tsx index 4322aab5b0..16fc00c03a 100644 --- a/src/components/Searchbar/Searchbar.tsx +++ b/src/components/Searchbar/Searchbar.tsx @@ -456,7 +456,6 @@ const styles = StyleSheet.create({ }, input: { flex: 1, - fontSize: 18, paddingLeft: SearchbarTokens.inputPaddingHorizontal, alignSelf: 'stretch', minWidth: 0, diff --git a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap index a14cbf905e..8beb5155ea 100644 --- a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap +++ b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap @@ -242,7 +242,6 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` { "alignSelf": "stretch", "flex": 1, - "fontSize": 18, "minWidth": 0, "paddingLeft": 8, }, diff --git a/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap b/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap index bb4e37cb0f..d7f9d3da53 100644 --- a/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap @@ -201,7 +201,6 @@ exports[`activity indicator snapshot test 1`] = ` { "alignSelf": "stretch", "flex": 1, - "fontSize": 18, "minWidth": 0, "paddingLeft": 8, }, @@ -625,7 +624,6 @@ exports[`renders with placeholder 1`] = ` { "alignSelf": "stretch", "flex": 1, - "fontSize": 18, "minWidth": 0, "paddingLeft": 8, }, @@ -1010,7 +1008,6 @@ exports[`renders with text 1`] = ` { "alignSelf": "stretch", "flex": 1, - "fontSize": 18, "minWidth": 0, "paddingLeft": 8, },