diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 72aee6d00f..29dfbf6e08 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -287,6 +287,39 @@ The `overlayAccessibilityLabel` prop was renamed to `dismissAccessibilityLabel`: ### Searchbar +The `Searchbar` modes use the latest Material 3 terminology in Paper 6.x: + +- **`mode="bar"`** → **`mode="contained"`** +- **`mode="view"`** → **`mode="divided"`** + +```tsx +// Before (v5) + + + +// After (v6) + + +``` + +The default `contained` mode adds 24dp horizontal margins and animates them to +12dp while focused. To keep a full-width Searchbar, provide a margin for that +axis; it replaces the built-in margin and disables the focus animation: + +```tsx + +``` + +Only a margin naming that axis - `marginHorizontal`, `marginStart`, `marginEnd`, +`marginLeft` or `marginRight` - replaces the animated one. The `margin` +shorthand is less specific, so the horizontal margins keep animating around it. +Use `marginVertical` when you only need vertical spacing and want to keep the +animated horizontal margins. + The misspelled `traileringIcon` props have been renamed: - **`traileringIcon`** → **`trailingIcon`** diff --git a/docs/component-docs.config.ts b/docs/component-docs.config.ts index 2cf106849c..8181c9c46b 100644 --- a/docs/component-docs.config.ts +++ b/docs/component-docs.config.ts @@ -130,7 +130,10 @@ const pages = { RadioButtonIOS: 'RadioButton/RadioButtonIOS', RadioButtonItem: 'RadioButton/RadioButtonItem', }, - Searchbar: 'Searchbar', + Searchbar: { + Searchbar: 'Searchbar/Searchbar', + SearchbarResults: 'Searchbar/SearchbarResults', + }, SegmentedButtons: { SegmentedButtons: 'SegmentedButtons/SegmentedButtons', }, diff --git a/docs/src/data/screenshots.ts b/docs/src/data/screenshots.ts index b7c59d424d..4c7805b8f6 100644 --- a/docs/src/data/screenshots.ts +++ b/docs/src/data/screenshots.ts @@ -126,8 +126,8 @@ export const screenshots = { }, 'RadioButton.Item': 'screenshots/radio-item.ios.png', Searchbar: { - bar: 'screenshots/searchbar.png', - view: 'screenshots/searchbar-view.png', + contained: 'screenshots/searchbar.png', + divided: 'screenshots/searchbar-view.png', }, SegmentedButtons: { 'single select': 'screenshots/segmented-button-single-select.png', diff --git a/docs/src/data/themeColors.ts b/docs/src/data/themeColors.ts index 118114da27..28545e57a4 100644 --- a/docs/src/data/themeColors.ts +++ b/docs/src/data/themeColors.ts @@ -272,11 +272,11 @@ export const themeColors = { }, Searchbar: { '-': { - backgroundColor: 'theme.colors.elevation.level3', - placeholderTextColor: 'theme.colors.onSurface', - textColor: 'theme.colors.onSurfaceVariant', + backgroundColor: 'theme.colors.surfaceContainerHigh', + placeholderTextColor: 'theme.colors.onSurfaceVariant', + textColor: 'theme.colors.onSurface', selectionColor: 'theme.colors.primary', - iconColor: 'theme.colors.onSurfaceVariant', + iconColor: 'theme.colors.onSurface', trailingIconColor: 'theme.colors.onSurfaceVariant', dividerColor: 'theme.colors.outline', }, diff --git a/example/src/Examples/SearchbarExample.tsx b/example/src/Examples/SearchbarExample.tsx index 1a3e98bb8f..85b89ea888 100644 --- a/example/src/Examples/SearchbarExample.tsx +++ b/example/src/Examples/SearchbarExample.tsx @@ -29,6 +29,7 @@ const SearchExample = () => { clickableBack: '', clickableDrawer: '', clickableLoading: '', + withResults: '', }); const { colors } = useTheme(); @@ -36,7 +37,7 @@ const SearchExample = () => { return ( <> - + @@ -44,7 +45,7 @@ const SearchExample = () => { } value={searchQueries.searchBarMode} style={styles.searchbar} - mode="bar" + mode="contained" /> { trailingIconAccessibilityLabel={'microphone button'} onTrailingIconPress={() => setIsVisible(true)} style={styles.searchbar} - mode="bar" + mode="contained" /> setSearchQuery({ @@ -87,7 +88,7 @@ const SearchExample = () => { style={styles.searchbar} /> setSearchQuery({ @@ -115,12 +116,12 @@ const SearchExample = () => { } value={searchQueries.loadingBarMode} style={styles.searchbar} - mode="bar" + mode="contained" loading trailingIcon={'microphone'} /> - + @@ -131,7 +132,7 @@ const SearchExample = () => { } value={searchQueries.searchViewMode} style={styles.searchbar} - mode="view" + mode="divided" /> { } value={searchQueries.searchWithoutBottomLine} style={styles.searchbar} - mode="view" + mode="divided" showDivider={false} /> { } value={searchQueries.loadingViewMode} style={styles.searchbar} - mode="view" + mode="divided" loading /> @@ -209,6 +210,30 @@ const SearchExample = () => { style={styles.searchbar} /> + + + setSearchQuery({ ...searchQueries, withResults: query }) + } + value={searchQueries.withResults} + style={styles.searchbar} + mode="contained" + /> + {searchQueries.withResults ? ( + + {['Apple', 'Apricot', 'Avocado', 'Banana', 'Blueberry'] + .filter((fruit) => + fruit + .toLowerCase() + .includes(searchQueries.withResults.toLowerCase()) + ) + .map((fruit) => ( + + ))} + + ) : null} + & { /** * Hint text shown when the input is empty. @@ -38,10 +88,17 @@ export type Props = Omit & { */ onChangeText?: (query: string) => void; /** - * @supported Available in v5.x with theme version 3 - * Search layout mode, the default value is "bar". + * Search layout mode, the default value is "contained". + * - `contained` - the recommended M3 Expressive style: a rounded bar + * whose horizontal margins animate from 24dp down to 12dp on focus + * (grow-wider effect). A `marginHorizontal`, `marginStart`, `marginEnd`, + * `marginLeft` or `marginRight` set through `style` replaces the animated + * margin on that side, so the bar keeps it. The `margin` shorthand does + * not, since a per-axis margin takes precedence over it. + * - `divided` - a full-bleed search view with square corners and a bottom + * `Divider`. Deprecated in M3 Expressive in favor of `contained`. */ - mode?: 'bar' | 'view'; + mode?: 'contained' | 'divided'; /** * Icon name for the left icon button (see `onIconPress`). */ @@ -81,13 +138,11 @@ export type Props = Omit & { */ clearTestID?: string; /** - * @supported Available in v5.x with theme version 3 * Icon name for the right trailing icon button. - * Works only when `mode` is set to "bar". It won't be displayed if `loading` is set to `true`. + * Works only when `mode` is set to "contained". It won't be displayed if `loading` is set to `true`. */ trailingIcon?: IconSource; /** - * @supported Available in v5.x with theme version 3 * Custom color for the right trailing icon, default will be derived from theme */ trailingIconColor?: ColorValue; @@ -104,9 +159,8 @@ export type Props = Omit & { */ trailingTestID?: string; /** - * @supported Available in v5.x with theme version 3 * Callback which returns a React element to display on the right side. - * Works only when `mode` is set to "bar". + * Works only when `mode` is set to "contained". */ right?: (props: { color: ColorValue; @@ -114,13 +168,11 @@ export type Props = Omit & { testID?: string; }) => React.ReactNode; /** - * @supported Available in v5.x with theme version 3 * Whether to show `Divider` at the bottom of the search. - * Works only when `mode` is set to "view". True by default. + * Works only when `mode` is set to "divided". True by default. */ showDivider?: boolean; /** - * @supported Available in v5.x with theme version 3 * Changes Searchbar shadow and background on iOS and Android. */ elevation?: Elevation; @@ -128,6 +180,13 @@ export type Props = Omit & { * Set style of the TextInput component inside the searchbar */ inputStyle?: StyleProp; + /** + * Style of the Searchbar. + * + * Applied to the `Surface` that draws the bar, along with any Reanimated + * style. Background color and border radius should be set through the + * `theme` instead. + */ style?: StyleProp; /** * Custom flag for replacing clear button with activity indicator. @@ -184,12 +243,12 @@ const Searchbar = ({ clearTestID, onClearIconPress, trailingIcon, - trailingIconColor, + trailingIconColor: customTrailingIconColor, trailingIconAccessibilityLabel, trailingTestID, onTrailingIconPress, right, - mode = 'bar', + mode = 'contained', showDivider = true, inputStyle, placeholder, @@ -199,14 +258,14 @@ const Searchbar = ({ value, loading = false, testID, + onFocus, + onBlur, ref, ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); - const { direction } = useLocale(); - const { colors, fonts } = theme; - + const reduceMotion = useReduceMotion(); const root = React.useRef(null); React.useImperativeHandle(ref, () => ({ @@ -228,28 +287,107 @@ const Searchbar = ({ const { dark } = theme; - const placeholderTextColor = theme.colors.onSurface; - const textColor = theme.colors.onSurfaceVariant; - const iconColor = customIconColor || theme.colors.onSurfaceVariant; + const { + containerColor, + inputColor, + placeholderColor, + leadingIconColor, + trailingIconColor, + cursorColor, + dividerColor, + } = React.useMemo(() => getSearchbarColors(theme), [theme]); + const iconColor = customIconColor || leadingIconColor; - const font = { - ...fonts.bodyLarge, - lineHeight: Platform.select({ - ios: 0, - default: fonts.bodyLarge.lineHeight, - }), - }; + const font = getSearchbarInputFont(theme); - const isBarMode = mode === 'bar'; + const isContained = mode === 'contained'; const inputTextAlign = direction === 'rtl' ? 'right' : 'left'; const shouldRenderTrailingIcon = - isBarMode && trailingIcon && !loading && (!value || right !== undefined); + isContained && trailingIcon && !loading && (!value || right !== undefined); + + const borderRadius = resolveCornerRadius( + theme, + isContained ? SearchbarTokens.contained : SearchbarTokens.divided + ); + + // A two-state transition, so a Reanimated CSS transition on a plain style + // placed before `style`, which lets the consumer override the margin. + const [focused, setFocused] = React.useState(false); + // Pressing one of the bar's own buttons blurs the input on web, and + // collapsing then moves the button out from under the pointer. The bar stays + // expanded until focus leaves it entirely. + const [focusHeldInside, setFocusHeldInside] = React.useState(false); + // An object ref would reach `Surface` as a `forwardedRef` prop. Typed as an + // `HTMLElement` so `focusout` resolves to a typed `FocusEvent` listener. + const barNodeRef = React.useRef(null); + const expanded = focused || focusHeldInside; + + const handleBarRef = React.useCallback((instance: View | null) => { + // On web react-native-web resolves a view ref to the DOM node it rendered. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + barNodeRef.current = instance as unknown as HTMLElement | null; + }, []); + + React.useEffect(() => { + if (!focusHeldInside || Platform.OS !== 'web') { + return; + } + + const node = barNodeRef.current; + + const handleFocusOut = (event: FocusEvent) => { + const staysInside = + event.relatedTarget instanceof Node && + !!node?.contains(event.relatedTarget); + if (!staysInside) { + setFocusHeldInside(false); + } + }; + + node?.addEventListener('focusout', handleFocusOut); + return () => node?.removeEventListener('focusout', handleFocusOut); + }, [focusHeldInside]); + + const handleFocus: NonNullable = (e) => { + setFocused(true); + onFocus?.(e); + }; + + const handleBlur: NonNullable = (e) => { + setFocused(false); + setFocusHeldInside(isFocusInsideBar(e, barNodeRef.current)); + onBlur?.(e); + }; + + const focusTransitionStyle: CSSStyle = { + marginHorizontal: expanded + ? SearchbarTokens.marginFocused + : SearchbarTokens.marginUnfocused, + transitionProperty: [ + // RNW writes this out as CSS `margin-inline`, which web has to name. + 'marginHorizontal', + ...(Platform.OS === 'web' + ? (['marginInline'] as SurfaceTransitionProperty[]) + : []), + ...SURFACE_TRANSITION_PROPERTIES, + ], + transitionTimingFunction: cubicBezier(...theme.motion.easing.standard), + }; return ( @@ -277,17 +415,17 @@ const Searchbar = ({ style={[ styles.input, { - color: textColor, + color: inputColor, ...font, ...Platform.select({ web: { outline: 'none' } }), textAlign: inputTextAlign, }, - isBarMode ? styles.barModeInput : styles.viewModeInput, + isContained ? styles.containedInput : styles.dividedInput, inputStyle, ]} placeholder={placeholder || ''} - placeholderTextColor={placeholderTextColor} - selectionColor={colors.primary} + placeholderTextColor={placeholderColor} + selectionColor={cursorColor} underlineColorAndroid="transparent" returnKeyType="search" keyboardAppearance={dark ? 'dark' : 'light'} @@ -295,6 +433,8 @@ const Searchbar = ({ ref={root} value={value} testID={testID} + onFocus={handleFocus} + onBlur={handleBlur} {...rest} /> {loading ? ( @@ -314,7 +454,7 @@ const Searchbar = ({ )) } + testID={clearTestID} role="button" theme={theme} - testID={clearTestID} /> )} @@ -338,21 +478,26 @@ const Searchbar = ({ role="button" borderless onPress={onTrailingIconPress} - iconColor={trailingIconColor || colors.onSurfaceVariant} + iconColor={customTrailingIconColor || trailingIconColor} icon={trailingIcon} aria-label={trailingIconAccessibilityLabel} + theme={theme} testID={trailingTestID} /> ) : null} - {isBarMode && - right?.({ color: textColor, style: styles.rightStyle, testID })} - {!isBarMode && showDivider && ( + {isContained && + right?.({ + color: trailingIconColor, + style: styles.rightStyle, + testID, + })} + {!isContained && showDivider && ( @@ -368,18 +513,17 @@ const styles = StyleSheet.create({ }, input: { flex: 1, - fontSize: 18, - paddingLeft: 8, + paddingLeft: SearchbarTokens.inputPaddingHorizontal, alignSelf: 'stretch', minWidth: 0, }, - barModeInput: { + containedInput: { paddingLeft: 0, - minHeight: 56, + minHeight: SearchbarTokens.minHeight, }, - viewModeInput: { + dividedInput: { paddingLeft: 0, - minHeight: 72, + minHeight: SearchbarTokens.dividedMinHeight, }, v3Loader: { marginHorizontal: 16, diff --git a/src/components/Searchbar/SearchbarResults.tsx b/src/components/Searchbar/SearchbarResults.tsx new file mode 100644 index 0000000000..6a0935f74e --- /dev/null +++ b/src/components/Searchbar/SearchbarResults.tsx @@ -0,0 +1,97 @@ +import * as React from 'react'; +import { StyleSheet } from 'react-native'; +import type { StyleProp } from 'react-native'; + +import { SearchbarTokens } from './tokens'; +import { getSearchbarColors } from './utils'; +import { useInternalTheme } from '../../core/theming'; +import type { Elevation, ThemeProp } from '../../theme/types'; +import { resolveCornerRadius } from '../../theme/utils/shape'; +import Surface from '../Surface'; +import type { SurfaceStyle } from '../Surface'; + +export type Props = { + /** + * Search results / suggestions to render inside the container. + */ + children: React.ReactNode; + /** + * Changes the container's shadow and background. + */ + elevation?: Elevation; + style?: StyleProp; + /** + * @optional + */ + theme?: ThemeProp; + /** + * TestID used for testing purposes + */ + testID?: string; +}; + +/** + * A container for the search results / suggestions list shown below a + * `Searchbar` (MD3 search anatomy element 6). It only provides the surface; + * grouping results with gaps is left to the consumer. It carries the + * horizontal margin of a focused contained bar, so the two line up the way the + * MD3 docked search view does; pass a margin through `style` to change that. + * + * ## Usage + * ```js + * import * as React from 'react'; + * import { Searchbar, List } from 'react-native-paper'; + * + * const MyComponent = () => { + * const [query, setQuery] = React.useState(''); + * + * return ( + * <> + * + * + * + * + * + * + * ); + * }; + * + * export default MyComponent; + * ``` + */ +const SearchbarResults = ({ + children, + elevation = 0, + style, + theme: themeOverrides, + testID, +}: Props) => { + const theme = useInternalTheme(themeOverrides); + const { resultsContainerColor } = React.useMemo( + () => getSearchbarColors(theme), + [theme] + ); + const borderRadius = resolveCornerRadius(theme, SearchbarTokens.results); + + return ( + + {children} + + ); +}; + +const styles = StyleSheet.create({ + container: { + // Margin rather than width: a width would add to it and overflow. + marginHorizontal: SearchbarTokens.marginFocused, + }, +}); + +export default SearchbarResults; diff --git a/src/components/Searchbar/index.tsx b/src/components/Searchbar/index.tsx new file mode 100644 index 0000000000..deedadf8e8 --- /dev/null +++ b/src/components/Searchbar/index.tsx @@ -0,0 +1,13 @@ +import SearchbarComponent from './Searchbar'; +import SearchbarResults from './SearchbarResults'; + +const Searchbar = Object.assign( + // @component ./Searchbar.tsx + SearchbarComponent, + { + // @component ./SearchbarResults.tsx + Results: SearchbarResults, + } +); + +export default Searchbar; diff --git a/src/components/Searchbar/tokens.ts b/src/components/Searchbar/tokens.ts new file mode 100644 index 0000000000..ccba7af324 --- /dev/null +++ b/src/components/Searchbar/tokens.ts @@ -0,0 +1,43 @@ +import type { ColorRole } from '../../theme/types'; +import type { ShapeToken } from '../../theme/utils/shape'; + +const sizes = { + /** Min height of the contained search bar (MD3: 56dp). */ + minHeight: 56, + /** Min height of the divided search view, which is taller. */ + dividedMinHeight: 72, + /** Horizontal gap between the input and its surrounding icons. */ + inputPaddingHorizontal: 8, + /** + * Horizontal margin around the contained bar when unfocused. On focus it + * animates down to `marginFocused`, producing the M3 Expressive + * "grow wider" effect. + */ + marginUnfocused: 24, + /** Horizontal margin around the contained bar when focused. */ + marginFocused: 12, +} as const; + +const shape = { + /** Contained search bar: fully rounded (28dp). */ + contained: 'extraLarge', + /** Divided search view: square corners. */ + divided: 'none', + /** Contained docked results: medium corners (12dp). */ + results: 'medium', +} as const satisfies Record; + +const colors = { + container: 'surfaceContainerHigh', + // Input/placeholder colors per MD3 — previously swapped in the legacy + // component (input was onSurfaceVariant, placeholder was onSurface). + input: 'onSurface', + placeholder: 'onSurfaceVariant', + leadingIcon: 'onSurface', + trailingIcon: 'onSurfaceVariant', + cursor: 'primary', + divider: 'outline', + resultsContainer: 'surfaceContainerHigh', +} as const satisfies Record; + +export const SearchbarTokens = { ...sizes, ...shape, ...colors }; diff --git a/src/components/Searchbar/utils.ts b/src/components/Searchbar/utils.ts new file mode 100644 index 0000000000..c4d959e118 --- /dev/null +++ b/src/components/Searchbar/utils.ts @@ -0,0 +1,51 @@ +import { Platform } from 'react-native'; +import type { ColorValue, TextStyle } from 'react-native'; + +import { SearchbarTokens } from './tokens'; +import type { InternalTheme } from '../../theme/types'; + +export type SearchbarColors = { + containerColor: ColorValue; + inputColor: ColorValue; + placeholderColor: ColorValue; + leadingIconColor: ColorValue; + trailingIconColor: ColorValue; + cursorColor: ColorValue; + dividerColor: ColorValue; + resultsContainerColor: ColorValue; +}; + +/** + * Resolve the Searchbar color-role tokens to concrete theme color values. + * Note `inputColor` is `onSurface` and `placeholderColor` is + * `onSurfaceVariant` — the MD3 spec order, fixing the legacy swap. + */ +export function getSearchbarColors(theme: InternalTheme): SearchbarColors { + const t = SearchbarTokens; + const c = theme.colors; + return { + containerColor: c[t.container], + inputColor: c[t.input], + placeholderColor: c[t.placeholder], + leadingIconColor: c[t.leadingIcon], + trailingIconColor: c[t.trailingIcon], + cursorColor: c[t.cursor], + dividerColor: c[t.divider], + resultsContainerColor: c[t.resultsContainer], + }; +} + +/** + * Resolve the input font from the `bodyLarge` typescale, zeroing `lineHeight` + * on iOS to avoid vertical clipping (mirrors the legacy behaviour). + */ +export function getSearchbarInputFont(theme: InternalTheme): TextStyle { + const { bodyLarge } = theme.fonts; + return { + ...bodyLarge, + lineHeight: Platform.select({ + ios: 0, + default: bodyLarge.lineHeight, + }), + }; +} diff --git a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap index faeb89916b..f16131205e 100644 --- a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap +++ b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap @@ -101,6 +101,7 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` /> { const tree = ( @@ -39,16 +49,31 @@ it('renders without ActivityIndicator', async () => { expect(screen.queryByRole('progressbar')).not.toBeOnTheScreen(); }); -it('renders clear icon with custom color', async () => { +it('uses the trailing color for the clear icon independently of the search icon', async () => { await render( ); expect( screen.getByText('close', { includeHiddenElements: true }) + ).toHaveStyle({ color: LightTheme.colors.onSurfaceVariant }); + expect( + screen.getByText('magnify', { includeHiddenElements: true }) ).toHaveStyle({ color: 'purple' }); }); +it('uses the MD3 color roles for the input and its placeholder', async () => { + const tree = ( + await render() + ).toJSON(); + + // `placeholderTextColor` is an attribute, not styling: the snapshot locks it. + expect(screen.getByRole('searchbox')).toHaveStyle({ + color: LightTheme.colors.onSurface, + }); + expect(tree).toMatchSnapshot(); +}); + it('does not respond to touch on the clear icon when search has no value', async () => { const onClearIconPressMock = jest.fn(); await render( @@ -90,14 +115,14 @@ it('hides the clear icon when a custom right element is rendered', async () => { expect(screen.queryByLabelText('clear')).not.toBeOnTheScreen(); }); -it('renders trailing icon when mode is set to "bar"', async () => { +it('renders trailing icon when mode is set to "contained"', async () => { await render( ); @@ -114,7 +139,7 @@ it('renders trailing icon with press functionality', async () => { trailingIcon={'microphone'} trailingIconAccessibilityLabel="microphone" onTrailingIconPress={onTrailingIconPressMock} - mode="bar" + mode="contained" /> ); @@ -129,7 +154,7 @@ it('renders clear icon instead of trailing icon', async () => { value={''} trailingIcon={'microphone'} trailingIconAccessibilityLabel="microphone" - mode="bar" + mode="contained" /> ); @@ -141,7 +166,7 @@ it('renders clear icon instead of trailing icon', async () => { value={'test'} trailingIcon={'microphone'} trailingIconAccessibilityLabel="microphone" - mode="bar" + mode="contained" /> ); @@ -149,8 +174,172 @@ it('renders clear icon instead of trailing icon', async () => { expect(screen.getByLabelText('clear')).toBeOnTheScreen(); }); -it('renders searchbar in "view" mode', async () => { - const tree = (await render()).toJSON(); +it('renders searchbar in "divided" mode', async () => { + const tree = (await render()).toJSON(); expect(tree).toMatchSnapshot(); }); + +it('forwards explicit test IDs to the input and each icon button', async () => { + const { rerender } = await render( + + ); + + expect(screen.getByTestId('query')).toBe(screen.getByRole('searchbox')); + expect(screen.getByTestId('search-button')).toBe( + screen.getByRole('button', { name: 'search' }) + ); + expect(screen.getByTestId('trailing-button')).toBeOnTheScreen(); + expect(screen.queryAllByTestId(/^query-/)).toHaveLength(0); + + await rerender(); + expect(screen.getByTestId('clear-button')).toBe( + screen.getByRole('button', { name: 'clear' }) + ); +}); + +it('does not assign default test IDs', async () => { + await render(); + expect(screen.queryAllByTestId(/./)).toHaveLength(0); +}); + +it('uses the custom trailing icon color', async () => { + await render( + + ); + expect( + screen.getByText('microphone', { includeHiddenElements: true }) + ).toHaveStyle({ color: 'purple' }); +}); + +it('applies layout styles to the outermost element', async () => { + await render( + + ); + expect(screen.root).toHaveStyle({ + flex: 1, + position: 'absolute', + top: 0, + transform: [{ scale: 1.5 }], + }); +}); + +it.each([{ marginHorizontal: 0 }, { marginInline: 0 }])( + 'respects custom horizontal margins %j', + async (style) => { + const tree = (await render()).toJSON(); + expect(screen.root).toHaveStyle(style); + expect(tree).toMatchSnapshot(); + } +); + +it.each([{ height: 80 }, { flex: 1 }])( + 'fills a custom vertical layout %j', + async (style) => { + const tree = (await render()).toJSON(); + expect(screen.root).toHaveStyle(style); + expect(tree).toMatchSnapshot(); + } +); + +it('applies Reanimated styles to the Searchbar', async () => { + const AnimatedSearchbar = () => { + const opacity = useSharedValue(0); + const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value })); + + return ( + ( + { + opacity.value = 1; + }} + > + Animate + + )} + /> + ); + }; + + await render(); + const root = screen.root; + if (!root) { + throw new Error('Searchbar did not render'); + } + expect(getAnimatedStyle(root)).toMatchObject({ opacity: 0 }); + + await userEvent.press(screen.getByLabelText('Animate Searchbar')); + await jest.runAllTimersAsync(); + + expect(getAnimatedStyle(root)).toMatchObject({ opacity: 1 }); +}); + +it('forwards onFocus and onBlur to the input', async () => { + const onFocus = jest.fn(); + const onBlur = jest.fn(); + await render(); + + await fireEvent(screen.getByRole('searchbox'), 'focus'); + await fireEvent(screen.getByRole('searchbox'), 'blur'); + + expect(onFocus).toHaveBeenCalledTimes(1); + expect(onBlur).toHaveBeenCalledTimes(1); +}); + +it('renders results below the Searchbar', async () => { + await render( + + Result + + ); + + expect(screen.getByText('Result')).toBeOnTheScreen(); + expect(screen.getByTestId('results')).toHaveStyle({ + borderRadius: LightTheme.shapes.corner.medium, + marginHorizontal: SearchbarTokens.marginFocused, + }); +}); + +it('shrinks its horizontal margin while the input is focused', async () => { + await render(); + + expect(screen.root).toHaveStyle({ + marginHorizontal: SearchbarTokens.marginUnfocused, + }); + + await fireEvent(screen.getByRole('searchbox'), 'focus'); + + expect(screen.root).toHaveStyle({ + marginHorizontal: SearchbarTokens.marginFocused, + }); +}); + +it('keeps a custom horizontal margin instead of animating it', async () => { + await render(); + + await fireEvent(screen.getByRole('searchbox'), 'focus'); + + expect(screen.root).toHaveStyle({ + marginHorizontal: 40, + }); +}); diff --git a/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap b/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap index c200e71706..a615199e39 100644 --- a/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap @@ -3,6 +3,7 @@ exports[`activity indicator snapshot test 1`] = ` `; -exports[`renders searchbar in "view" mode 1`] = ` +exports[`fills a custom vertical layout {"flex":1} 1`] = ` - `; -exports[`renders with placeholder 1`] = ` +exports[`fills a custom vertical layout {"height":80} 1`] = ` `; -exports[`renders with text 1`] = ` +exports[`renders searchbar in "divided" mode 1`] = ` + + + + + + magnify + + + + + + + + + + + close + + + + + + + +`; + +exports[`renders with placeholder 1`] = ` + + + + + + + magnify + + + + + + + + + + + close + + + + + + +`; + +exports[`renders with text 1`] = ` + + + + + + + magnify + + + + + + + + + + + close + + + + + + +`; + +exports[`respects custom horizontal margins {"marginHorizontal":0} 1`] = ` + + + + + + + magnify + + + + + + + + + + + close + + + + + + +`; + +exports[`respects custom horizontal margins {"marginInline":0} 1`] = ` + + + + + + + magnify + + + + + + + + + + + close + + + + + + +`; + +exports[`uses the MD3 color roles for the input and its placeholder 1`] = ` +