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