From 06f53a5546bfade01d0ef4dd40f8731d0a12b69a Mon Sep 17 00:00:00 2001 From: Grzegorz Kruk Date: Tue, 29 Sep 2026 15:42:41 +0200 Subject: [PATCH] refactor(snackbar)!: modernize layout and action API Move the Snackbar to its own directory and align it with the MD3 spec. - The message is passed via the `message` prop instead of `children`, which removes the arbitrary child composition the layout had to account for. - `action` takes a typed `SnackbarAction` instead of every `Button` prop, and dismisses the Snackbar on its own. - `contentStyle` becomes `messageStyle`, applied to the message text. - The Snackbar renders in a `Portal` by default, so it overlays all other content without the consumer wrapping it. Set `portal={false}` to opt out. - Layout, color roles and motion come from `tokens.ts` and the theme: 48dp minimum height, 600dp max width, 16/14dp message padding, `extraSmall` shape via `resolveCornerRadius`, and `inverseSurface` / `inverseOnSurface` / `inversePrimary`. - An action which doesn't fit next to the message wraps onto its own trailing line, as the spec's longer-action configuration asks for. - The enter/exit animations use the theme motion tokens, and the auto-dismiss timer starts once the enter animation has settled. Refs #4932 Co-authored-by: Cursor --- docs/6.x/docs/guides/migration.md | 45 + docs/component-docs.config.ts | 8 +- example/src/Examples/AppbarExample.tsx | 5 +- example/src/Examples/ChipExample.tsx | 5 +- example/src/Examples/SearchbarExample.tsx | 5 +- example/src/Examples/SnackbarExample.tsx | 73 +- src/components/Snackbar.tsx | 407 --------- src/components/Snackbar/Snackbar.tsx | 496 ++++++++++ src/components/Snackbar/tokens.ts | 50 ++ src/components/__tests__/Snackbar.test.tsx | 93 -- .../__tests__/Snackbar/Snackbar.test.tsx | 190 ++++ .../__snapshots__/Snackbar.test.tsx.snap | 532 +++++++++++ .../__snapshots__/Snackbar.test.tsx.snap | 846 ------------------ src/index.tsx | 7 +- 14 files changed, 1358 insertions(+), 1404 deletions(-) delete mode 100644 src/components/Snackbar.tsx create mode 100644 src/components/Snackbar/Snackbar.tsx create mode 100644 src/components/Snackbar/tokens.ts delete mode 100644 src/components/__tests__/Snackbar.test.tsx create mode 100644 src/components/__tests__/Snackbar/Snackbar.test.tsx create mode 100644 src/components/__tests__/Snackbar/__snapshots__/Snackbar.test.tsx.snap delete mode 100644 src/components/__tests__/__snapshots__/Snackbar.test.tsx.snap diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 9b73d648ed..a297e31b9e 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -289,6 +289,51 @@ The misspelled `traileringIcon` props have been renamed: /> ``` +### Snackbar + +The message is passed via the `message` prop instead of `children`, and the +`action` prop takes a narrow set of options instead of every `Button` prop: + +```diff + {} }} +-> +- Changes saved +- ++/> +``` + +Passing arbitrary nodes as the message is no longer supported, so compose the +message as a string. The `contentStyle` prop was renamed to `messageStyle`, and +it styles the message text rather than a wrapper around arbitrary children. + +The Snackbar now renders in a `Portal` on its own, so it overlays all other +content. Wrapping it in a `Portal` is no longer needed — and would render it +twice. Set `portal={false}` to keep it inside the parent instead. + +```diff +- +- +- ++ +``` + +The action dismisses the Snackbar on its own, so calling `onDismiss` from +`action.onPress` is no longer needed: + +```diff + { doUndo(); onDismiss(); } }} ++ action={{ label: 'Undo', onPress: () => doUndo() }} +/> +``` + ### TextInput The Paper 6.x `TextInput` is a complete rewrite with a new API. Import the component the same way, but note that the props and behavior have changed significantly. diff --git a/docs/component-docs.config.ts b/docs/component-docs.config.ts index 2cf106849c..c6c3d6264a 100644 --- a/docs/component-docs.config.ts +++ b/docs/component-docs.config.ts @@ -134,7 +134,9 @@ const pages = { SegmentedButtons: { SegmentedButtons: 'SegmentedButtons/SegmentedButtons', }, - Snackbar: 'Snackbar', + Snackbar: { + Snackbar: 'Snackbar/Snackbar', + }, Surface: 'Surface', Switch: { Switch: 'Switch/Switch', @@ -172,10 +174,6 @@ const componentDocsConfig: ComponentDocsConfig = { 'Comprehensive Portal example': 'https://snack.expo.dev/@react-native-paper/more-examples---comprehensive-portal-example', }, - Snackbar: { - 'Snackbar rendered regardless of the parent positioning': - 'https://snack.expo.dev/@react-native-paper/more-examples---snackbar-rendered-regardless-of-the-parent-positioning', - }, }, knownIssues: {}, themeColors, diff --git a/example/src/Examples/AppbarExample.tsx b/example/src/Examples/AppbarExample.tsx index 2d4eb0bbc4..3f1af87dbf 100644 --- a/example/src/Examples/AppbarExample.tsx +++ b/example/src/Examples/AppbarExample.tsx @@ -184,10 +184,9 @@ const AppbarExample = () => { setShowSnackbar(false)} + message="Heading pressed" duration={Snackbar.DURATION_SHORT} - > - Heading pressed - + /> ); }; diff --git a/example/src/Examples/ChipExample.tsx b/example/src/Examples/ChipExample.tsx index c6c817041f..3a244c807e 100644 --- a/example/src/Examples/ChipExample.tsx +++ b/example/src/Examples/ChipExample.tsx @@ -372,10 +372,9 @@ const ChipExample = () => { setSnackbarProperties({ visible: false, text: '' })} + message={snackbarProperties.text} duration={Snackbar.DURATION_SHORT} - > - {snackbarProperties.text} - + /> ); }; diff --git a/example/src/Examples/SearchbarExample.tsx b/example/src/Examples/SearchbarExample.tsx index 1a3e98bb8f..4fff3985e5 100644 --- a/example/src/Examples/SearchbarExample.tsx +++ b/example/src/Examples/SearchbarExample.tsx @@ -213,10 +213,9 @@ const SearchExample = () => { setIsVisible(false)} + message="Microphone button pressed" duration={Snackbar.DURATION_SHORT} - > - Microphone button pressed - + /> ); }; diff --git a/example/src/Examples/SnackbarExample.tsx b/example/src/Examples/SnackbarExample.tsx index e31955f18c..9894ac7a3d 100644 --- a/example/src/Examples/SnackbarExample.tsx +++ b/example/src/Examples/SnackbarExample.tsx @@ -19,6 +19,7 @@ const SnackbarExample = () => { showCloseIcon: false, showLongerMessage: false, showLongerAction: false, + showInPortal: true, }); const { @@ -27,14 +28,11 @@ const SnackbarExample = () => { showCloseIcon, showLongerMessage, showLongerAction, + showInPortal, } = options; - const action = { - label: showLongerAction ? 'Toggle Theme' : 'Action', - onPress: () => { - preferences?.toggleTheme(); - }, - }; + const toggleOption = (option: keyof typeof options) => + setOptions({ ...options, [option]: !options[option] }); return ( <> @@ -44,71 +42,65 @@ const SnackbarExample = () => { Action button - setOptions({ ...options, showAction: !showAction }) - } + onValueChange={() => toggleOption('showAction')} /> Close icon button - setOptions({ ...options, showCloseIcon: !showCloseIcon }) - } + onValueChange={() => toggleOption('showCloseIcon')} /> Longer message - setOptions({ - ...options, - showLongerMessage: !showLongerMessage, - }) - } + onValueChange={() => toggleOption('showLongerMessage')} /> Longer action - setOptions({ - ...options, - showLongerAction: !showLongerAction, - }) - } + onValueChange={() => toggleOption('showLongerAction')} + /> + + + Render in portal + toggleOption('showInPortal')} /> - setOptions({ ...options, showSnackbar: false })} - action={showAction ? action : undefined} - onIconPress={ - showCloseIcon - ? () => setOptions({ ...options, showSnackbar: false }) + onDismiss={() => toggleOption('showSnackbar')} + message={showLongerMessage ? LONG_MESSAGE : SHORT_MESSAGE} + action={ + showAction + ? { + label: showLongerAction ? 'Toggle theme' : 'Action', + onPress: () => { + preferences?.toggleTheme(); + }, + } : undefined } + onIconPress={ + showCloseIcon ? () => toggleOption('showSnackbar') : undefined + } + portal={showInPortal} duration={Snackbar.DURATION_MEDIUM} - style={showLongerAction && styles.longerAction} - > - {showLongerMessage ? LONG_MESSAGE : SHORT_MESSAGE} - + /> ); }; @@ -130,9 +122,6 @@ const styles = StyleSheet.create({ paddingVertical: 8, paddingHorizontal: 16, }, - longerAction: { - flexDirection: 'column', - }, }); export default SnackbarExample; diff --git a/src/components/Snackbar.tsx b/src/components/Snackbar.tsx deleted file mode 100644 index fac1f7a049..0000000000 --- a/src/components/Snackbar.tsx +++ /dev/null @@ -1,407 +0,0 @@ -import * as React from 'react'; -import { StyleSheet, View } from 'react-native'; -import type { StyleProp, ViewProps, ViewStyle } from 'react-native'; - -import { - Easing, - interpolate, - ReduceMotion, - useAnimatedStyle, - useSharedValue, - withTiming, -} from 'react-native-reanimated'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { scheduleOnRN } from 'react-native-worklets'; -import useLatestCallback from 'use-latest-callback'; - -import Button from './Button/Button'; -import type { Props as ButtonProps } from './Button/Button'; -import type { IconSource } from './Icon'; -import IconButton from './IconButton/IconButton'; -import MaterialCommunityIcon from './MaterialCommunityIcon'; -import Surface from './Surface'; -import type { SurfaceStyle } from './Surface'; -import Text from './Typography/Text'; -import { useLocale } from '../core/locale'; -import { useInternalTheme } from '../core/theming'; -import type { Elevation, ThemeProp } from '../theme/types'; - -export type Props = Omit & { - /** - * Whether the Snackbar is currently visible. - */ - visible: boolean; - /** - * Label and press callback for the action button. It should contain the following properties: - * - `label` - Label of the action button - * - `onPress` - Callback that is called when action button is pressed. - */ - action?: Omit, 'children'> & { - label: string; - }; - /** - * @supported Available in v5.x with theme version 3 - * Icon to display when `onIconPress` is defined. Default will be `close` icon. - */ - icon?: IconSource; - /** - * Function to execute on icon button press. The icon button appears only when this prop is specified. - */ - onIconPress?: () => void; - /** - * @supported Available in v5.x with theme version 3 - * Accessibility label for the icon button. This is read by the screen reader when the user taps the button. - */ - iconAccessibilityLabel?: string; - /** - * testID for the icon button. - */ - iconTestID?: string; - /** - * The duration for which the Snackbar is shown. - */ - duration?: number; - /** - * Callback called when Snackbar is dismissed. The `visible` prop needs to be updated when this is called. - */ - onDismiss: () => void; - /** - * Text content of the Snackbar. - */ - children: React.ReactNode; - /** - * @supported Available in v5.x with theme version 3 - * Changes Snackbar shadow and background on iOS and Android. - */ - elevation?: Elevation; - /** - * Specifies the largest possible scale a text font can reach. - */ - maxFontSizeMultiplier?: number; - /** - * Style for the wrapper of the snackbar - */ - wrapperStyle?: StyleProp; - /** - * Style for the content of the snackbar - */ - contentStyle?: StyleProp; - style?: StyleProp; - ref?: React.RefObject; - /** - * @optional - */ - theme?: ThemeProp; - /** - * TestID used for testing purposes - */ - testID?: string; -}; - -const DURATION_SHORT = 4000; -const DURATION_MEDIUM = 7000; -const DURATION_LONG = 10000; - -/** - * Snackbars provide brief feedback about an operation through a message rendered at the bottom of the container in which it's wrapped. - * - * Note: To display it as a popup, regardless of the parent's position, wrap it with a `Portal` component – refer to the example in the "More Examples` section. - * - * ## Usage - * ```js - * import * as React from 'react'; - * import { View, StyleSheet } from 'react-native'; - * import { Button, Snackbar } from 'react-native-paper'; - * - * const MyComponent = () => { - * const [visible, setVisible] = React.useState(false); - * - * const onToggleSnackBar = () => setVisible(!visible); - * - * const onDismissSnackBar = () => setVisible(false); - * - * return ( - * - * - * { - * // Do something - * }, - * }}> - * Hey there! I'm a Snackbar. - * - * - * ); - * }; - * - * const styles = StyleSheet.create({ - * container: { - * flex: 1, - * justifyContent: 'space-between', - * }, - * }); - * - * export default MyComponent; - * ``` - */ -const Snackbar = ({ - visible, - action, - icon, - onIconPress, - iconAccessibilityLabel = 'Close icon', - iconTestID, - duration = DURATION_MEDIUM, - onDismiss, - children, - elevation = 2, - style, - wrapperStyle, - contentStyle, - theme: themeOverrides, - maxFontSizeMultiplier, - testID, - ...rest -}: Props) => { - const theme = useInternalTheme(themeOverrides); - - const { direction } = useLocale(); - - const { bottom, right, left } = useSafeAreaInsets(); - - const opacity = useSharedValue(0); - - const hideTimeout = React.useRef | undefined>( - undefined - ); - const isMounted = React.useRef(true); - - const [hidden, setHidden] = React.useState(!visible); - - const { scale } = theme.animation; - - if (visible && hidden) { - setHidden(false); - } - - const handleShowAnimationFinished = useLatestCallback((finished: boolean) => { - if (!finished || !visible || !isMounted.current) { - return; - } - - const isInfinity = - duration === Number.POSITIVE_INFINITY || - duration === Number.NEGATIVE_INFINITY; - - if (!isInfinity) { - hideTimeout.current = setTimeout(onDismiss, duration); - } - }); - - React.useEffect(() => { - if (hideTimeout.current) { - clearTimeout(hideTimeout.current); - hideTimeout.current = undefined; - } - - opacity.value = withTiming( - visible ? 1 : 0, - { - duration: (visible ? 200 : 100) * scale, - easing: visible ? Easing.out(Easing.ease) : Easing.inOut(Easing.ease), - reduceMotion: ReduceMotion.Never, - }, - (finished) => { - if (visible) { - scheduleOnRN(handleShowAnimationFinished, finished ?? false); - } else if (finished) { - scheduleOnRN(setHidden, true); - } - } - ); - }, [handleShowAnimationFinished, opacity, scale, visible]); - - React.useEffect(() => { - isMounted.current = true; - - return () => { - isMounted.current = false; - - if (hideTimeout.current) { - clearTimeout(hideTimeout.current); - } - }; - }, []); - - const animatedStyle = useAnimatedStyle(() => ({ - opacity: opacity.value, - transform: [ - { - scale: visible ? interpolate(opacity.value, [0, 1], [0.9, 1]) : 1, - }, - ], - })); - - const { colors } = theme; - - if (hidden) { - return null; - } - - const { - style: actionStyle, - label: actionLabel, - onPress: onPressAction, - ...actionProps - } = action || {}; - - const buttonTextColor = colors.inversePrimary; - const textColor = colors.inverseOnSurface; - const backgroundColor = colors.inverseSurface; - - const isIconButton = Boolean(onIconPress); - - const marginLeft = action ? -12 : -16; - - const wrapperPaddings = { - paddingBottom: bottom, - paddingHorizontal: Math.max(left, right), - }; - - const content = - typeof children === 'string' ? ( - - {children} - - ) : ( - - {/* View is added to allow multiple lines support for Text component as children */} - {children} - - ); - - return ( - - - {content} - {(action || isIconButton) && ( - - {action ? ( - - ) : null} - {isIconButton ? ( - { - return ( - - ); - }) - } - aria-label={iconAccessibilityLabel} - style={styles.icon} - testID={iconTestID} - /> - ) : null} - - )} - - - ); -}; - -/** - * Show the Snackbar for a short duration. - */ -Snackbar.DURATION_SHORT = DURATION_SHORT; - -/** - * Show the Snackbar for a medium duration. - */ -Snackbar.DURATION_MEDIUM = DURATION_MEDIUM; - -/** - * Show the Snackbar for a long duration. - */ -Snackbar.DURATION_LONG = DURATION_LONG; - -const styles = StyleSheet.create({ - wrapper: { - position: 'absolute', - bottom: 0, - width: '100%', - }, - container: { - flexDirection: 'row', - justifyContent: 'space-between', - margin: 8, - minHeight: 48, - pointerEvents: 'box-none', - }, - content: { - marginHorizontal: 16, - marginVertical: 14, - flex: 1, - }, - actionsContainer: { - flexDirection: 'row', - justifyContent: 'flex-end', - alignItems: 'center', - minHeight: 48, - }, - button: { - marginRight: 8, - marginLeft: 4, - }, - icon: { - width: 40, - height: 40, - margin: 0, - }, -}); - -export default Snackbar; diff --git a/src/components/Snackbar/Snackbar.tsx b/src/components/Snackbar/Snackbar.tsx new file mode 100644 index 0000000000..b29e9fcf8d --- /dev/null +++ b/src/components/Snackbar/Snackbar.tsx @@ -0,0 +1,496 @@ +import * as React from 'react'; +import { StyleSheet, View } from 'react-native'; +import type { + ColorValue, + GestureResponderEvent, + StyleProp, + TextStyle, + ViewProps, + ViewStyle, +} from 'react-native'; + +import { + Easing, + interpolate, + ReduceMotion, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { scheduleOnRN } from 'react-native-worklets'; +import useLatestCallback from 'use-latest-callback'; + +import { SnackbarTokens } from './tokens'; +import { useLocale } from '../../core/locale'; +import { useInternalTheme } from '../../core/theming'; +import type { Elevation, Theme, ThemeProp } from '../../theme/types'; +import { resolveCornerRadius } from '../../theme/utils/shape'; +import Button from '../Button/Button'; +import type { IconSource } from '../Icon'; +import IconButton from '../IconButton/IconButton'; +import MaterialCommunityIcon from '../MaterialCommunityIcon'; +import Portal from '../Portal/Portal'; +import Surface from '../Surface'; +import type { SurfaceStyle } from '../Surface'; +import Text from '../Typography/Text'; + +/** + * Action rendered by the `Snackbar` next to its message. + */ +export type SnackbarAction = { + /** + * Label of the action. + */ + label: string; + /** + * Callback which fires when the action is pressed. The Snackbar dismisses + * itself right after, so there's no need to call `onDismiss` from here. + */ + onPress?: (event: GestureResponderEvent) => void; + /** + * Accessibility label of the action, read by the screen reader when the user + * focuses it. Defaults to `label`. + */ + accessibilityLabel?: string; + /** + * testID for the action. + */ + testID?: string; + /** + * Style for the action. + */ + style?: StyleProp; +}; + +export type Props = Omit & { + /** + * Whether the Snackbar is currently visible. + */ + visible: boolean; + /** + * Message shown by the Snackbar. It's meant for a short single line of text + * and wraps onto a second line when it doesn't fit. + */ + message: string; + /** + * Action displayed next to the message. The spec allows one action at most. + */ + action?: SnackbarAction; + /** + * Icon to display when `onIconPress` is defined. Defaults to the `close` icon. + */ + icon?: IconSource; + /** + * Function to execute on icon button press. The icon button appears only when this prop is specified. + */ + onIconPress?: () => void; + /** + * Accessibility label for the icon button. This is read by the screen reader when the user taps the button. + */ + iconAccessibilityLabel?: string; + /** + * testID for the icon button. + */ + iconTestID?: string; + /** + * The duration in milliseconds for which the Snackbar is shown. + * Pass `Infinity` to keep it on screen until it's dismissed manually. + */ + duration?: number; + /** + * Callback called when Snackbar is dismissed. The `visible` prop needs to be updated when this is called. + */ + onDismiss: () => void; + /** + * Changes Snackbar shadow and background on iOS and Android. + */ + elevation?: Elevation; + /** + * Whether the Snackbar is rendered in a `Portal`, so that it overlays all + * other content no matter where it sits in the tree. Set it to `false` to + * render it within the parent, for example when it should be constrained to + * a part of the screen. + */ + portal?: boolean; + /** + * Specifies the largest possible scale a text font can reach. + */ + maxFontSizeMultiplier?: number; + /** + * Style for the wrapper of the Snackbar, the layer which pins it to the + * bottom of the screen. + */ + wrapperStyle?: StyleProp; + /** + * Style for the message of the Snackbar. + */ + messageStyle?: StyleProp; + /** + * Style for the container of the Snackbar. + */ + style?: StyleProp; + ref?: React.RefObject; + /** + * @optional + */ + theme?: ThemeProp; + /** + * TestID used for testing purposes + */ + testID?: string; +}; + +const DURATION_SHORT = 4000; +const DURATION_MEDIUM = 7000; +const DURATION_LONG = 10000; + +const { + containerShape, + minHeight, + screenMargin, + containerMaxWidth, + messageSpacing, + messageTrailingSpacing, + messageVerticalSpacing, + messageInlineBasis, + actionSpacing, + iconSize, + containerColor, + messageColor, + actionColor, + iconColor, +} = SnackbarTokens; + +type ActionButtonProps = { + action: SnackbarAction; + color: ColorValue; + theme: Theme; + onDismiss: () => void; +}; + +const ActionButton = ({ + action, + color, + theme, + onDismiss, +}: ActionButtonProps) => ( + +); + +/** + * Snackbars provide brief feedback about an operation through a message at the + * bottom of the screen. They are rendered in a `Portal` by default, so that + * they overlay all other content. + * + * ## Usage + * ```js + * import * as React from 'react'; + * import { View, StyleSheet } from 'react-native'; + * import { Button, Snackbar } from 'react-native-paper'; + * + * const MyComponent = () => { + * const [visible, setVisible] = React.useState(false); + * + * const onToggleSnackBar = () => setVisible(!visible); + * + * const onDismissSnackBar = () => setVisible(false); + * + * return ( + * + * + * { + * // Do something + * }, + * }} + * /> + * + * ); + * }; + * + * const styles = StyleSheet.create({ + * container: { + * flex: 1, + * justifyContent: 'space-between', + * }, + * }); + * + * export default MyComponent; + * ``` + */ +const Snackbar = ({ + visible, + message, + action, + icon, + onIconPress, + iconAccessibilityLabel = 'Close icon', + iconTestID, + duration = DURATION_MEDIUM, + onDismiss, + elevation = 2, + portal = true, + maxFontSizeMultiplier, + wrapperStyle, + messageStyle, + style, + theme: themeOverrides, + testID, + ...rest +}: Props) => { + const theme = useInternalTheme(themeOverrides); + const { direction } = useLocale(); + const { bottom, right, left } = useSafeAreaInsets(); + + const opacity = useSharedValue(0); + + const hideTimeout = React.useRef | undefined>( + undefined + ); + + const [mounted, setMounted] = React.useState(visible); + + const clearHideTimeout = React.useCallback(() => { + if (hideTimeout.current) { + clearTimeout(hideTimeout.current); + hideTimeout.current = undefined; + } + }, []); + + // The auto-dismiss timer starts once the enter animation has settled, so the + // message stays on screen for the whole `duration`. + const handleShown = useLatestCallback(() => { + clearHideTimeout(); + + if (duration !== Number.POSITIVE_INFINITY) { + hideTimeout.current = setTimeout(onDismiss, duration); + } + }); + + const { + animation: { scale }, + motion: { duration: motionDuration, easing }, + } = theme; + + React.useEffect(() => { + clearHideTimeout(); + + if (!visible) { + opacity.value = withTiming( + 0, + { + duration: motionDuration.short4 * scale, + easing: Easing.bezier(...easing.emphasizedAccelerate), + reduceMotion: ReduceMotion.Never, + }, + (finished) => { + if (finished) { + scheduleOnRN(setMounted, false); + } + } + ); + + return; + } + + setMounted(true); + + opacity.value = withTiming( + 1, + { + duration: motionDuration.medium2 * scale, + easing: Easing.bezier(...easing.emphasizedDecelerate), + reduceMotion: ReduceMotion.Never, + }, + (finished) => { + if (finished) { + scheduleOnRN(handleShown); + } + } + ); + }, [ + clearHideTimeout, + easing.emphasizedAccelerate, + easing.emphasizedDecelerate, + handleShown, + motionDuration.medium2, + motionDuration.short4, + opacity, + scale, + visible, + ]); + + React.useEffect(() => clearHideTimeout, [clearHideTimeout]); + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + transform: [{ scale: interpolate(opacity.value, [0, 1], [0.9, 1]) }], + })); + + // Kept stable across renders, so that the icon isn't remounted on every update. + const closeIcon = React.useMemo( + () => + ({ size, color }: { size: number; color: ColorValue }) => ( + + ), + [direction] + ); + + if (!mounted) { + return null; + } + + const { colors } = theme; + const isIconButton = Boolean(onIconPress); + + const snackbar = ( + + + + {message} + + {action || isIconButton ? ( + + {action ? ( + + ) : null} + {isIconButton ? ( + + ) : null} + + ) : null} + + + ); + + return portal ? {snackbar} : snackbar; +}; + +/** + * Show the Snackbar for a short duration. + */ +Snackbar.DURATION_SHORT = DURATION_SHORT; + +/** + * Show the Snackbar for a medium duration. + */ +Snackbar.DURATION_MEDIUM = DURATION_MEDIUM; + +/** + * Show the Snackbar for a long duration. + */ +Snackbar.DURATION_LONG = DURATION_LONG; + +const styles = StyleSheet.create({ + wrapper: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + alignItems: 'center', + pointerEvents: 'box-none', + }, + container: { + width: '100%', + maxWidth: containerMaxWidth, + flexDirection: 'row', + flexWrap: 'wrap', + // Lines are trailing-aligned, so an action wrapped onto its own line sits + // at the trailing edge below the message. + justifyContent: 'flex-end', + margin: screenMargin, + minHeight, + pointerEvents: 'box-none', + }, + message: { + // The message takes the space the action doesn't need, and claims only + // `messageInlineBasis` up front, so an action which doesn't fit next to it + // wraps onto its own line. + flexBasis: messageInlineBasis, + flexGrow: 1, + marginStart: messageSpacing, + marginEnd: messageTrailingSpacing, + marginVertical: messageVerticalSpacing, + }, + actions: { + flexDirection: 'row', + alignItems: 'center', + // The actions are shorter than the message line they share, and than the + // message itself once they wrap onto their own line, so they stay centered + // against it either way. + alignSelf: 'center', + marginEnd: actionSpacing, + }, + icon: { + margin: 0, + }, +}); + +export default Snackbar; diff --git a/src/components/Snackbar/tokens.ts b/src/components/Snackbar/tokens.ts new file mode 100644 index 0000000000..8a39f5310a --- /dev/null +++ b/src/components/Snackbar/tokens.ts @@ -0,0 +1,50 @@ +import type { ColorRole } from '../../theme/types'; +import type { ShapeToken } from '../../theme/utils/shape'; + +/** + * MD3 Snackbar spec dimensions. + * @see https://m3.material.io/components/snackbar/specs + */ +const sizes = { + /** Corner radius of the container: `md.sys.shape.corner.extra-small`. */ + containerShape: 'extraSmall' as ShapeToken, + /** Height of a single-line Snackbar, which is also its minimum height. */ + minHeight: 48, + /** Gap between the container and the edges of the window. */ + screenMargin: 8, + /** Widest the container gets, so that a message keeps a readable line length. */ + containerMaxWidth: 600, + /** Gap between the message and the leading edge of the container. */ + messageSpacing: 16, + /** + * Gap between the message and the trailing edge of the container, which also + * separates it from an action on the same line. + */ + messageTrailingSpacing: 8, + /** Top and bottom padding of the message, which yields the single-line height. */ + messageVerticalSpacing: 14, + /** + * Share of the container the message starts with. An action which doesn't fit + * in what's left — a long action label on a narrow screen — moves onto its own + * line below the message, as the "two lines with longer action" configuration + * of the spec asks for. + */ + messageInlineBasis: '60%' as const, + /** Gap between the action area and the trailing edge of the container. */ + actionSpacing: 8, + /** Size of the icon inside the trailing icon button. */ + iconSize: 24, +} as const; + +/** + * MD3 Snackbar color roles. + * @see https://m3.material.io/components/snackbar/specs + */ +const colors = { + containerColor: 'inverseSurface', + messageColor: 'inverseOnSurface', + actionColor: 'inversePrimary', + iconColor: 'inverseOnSurface', +} as const satisfies Record; + +export const SnackbarTokens = { ...sizes, ...colors }; diff --git a/src/components/__tests__/Snackbar.test.tsx b/src/components/__tests__/Snackbar.test.tsx deleted file mode 100644 index 5c30f7cc6e..0000000000 --- a/src/components/__tests__/Snackbar.test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { StyleSheet, Text, View } from 'react-native'; - -import { expect, it, jest } from '@jest/globals'; - -import { render } from '../../test-utils'; -import { red200, white } from '../../theme/colors'; -import Snackbar from '../Snackbar'; - -const styles = StyleSheet.create({ - snackContent: { - flexDirection: 'row', - alignItems: 'center', - }, - iconView: { - backgroundColor: red200, - padding: 15, - }, - text: { color: white, marginLeft: 10, flexWrap: 'wrap', flexShrink: 1 }, -}); - -jest.mock('react-native-safe-area-context', () => ({ - useSafeAreaInsets: () => ({ bottom: 34, left: 0, right: 0, top: 47 }), -})); - -it('renders snackbar with content', async () => { - const tree = ( - await render( - - Snackbar content - - ) - ).toJSON(); - - expect(tree).toMatchSnapshot(); -}); - -it('renders not visible snackbar with content wrapper but no actual content', async () => { - const tree = ( - await render( - - Snackbar content - - ) - ).toJSON(); - - expect(tree).toMatchSnapshot(); -}); - -it('renders snackbar with Text as a child', async () => { - const tree = ( - await render( - - Snackbar content - - ) - ).toJSON(); - - expect(tree).toMatchSnapshot(); -}); - -it('renders snackbar with action button', async () => { - const tree = ( - await render( - {}} - action={{ label: 'Undo', onPress: jest.fn() }} - > - Snackbar content - - ) - ).toJSON(); - - expect(tree).toMatchSnapshot(); -}); - -it('renders snackbar with View & Text as a child', async () => { - const tree = ( - await render( - - - - - Error Message which is veryyyyyyyyyyyy longggggggg Error Message - which is veryyyyyyyyyyyy longggggggg - - - - ) - ).toJSON(); - - expect(tree).toMatchSnapshot(); -}); diff --git a/src/components/__tests__/Snackbar/Snackbar.test.tsx b/src/components/__tests__/Snackbar/Snackbar.test.tsx new file mode 100644 index 0000000000..c4b5204a6b --- /dev/null +++ b/src/components/__tests__/Snackbar/Snackbar.test.tsx @@ -0,0 +1,190 @@ +import { StyleSheet } from 'react-native'; + +import { afterEach, describe, expect, it, jest } from '@jest/globals'; +import { act, userEvent } from '@testing-library/react-native'; + +import { render, screen } from '../../../test-utils'; +import Portal from '../../Portal/Portal'; +import Snackbar from '../../Snackbar/Snackbar'; + +const styles = StyleSheet.create({ + wrapper: { paddingBottom: 16 }, + message: { textAlign: 'center' }, + container: { margin: 12 }, +}); + +const renderSnackbar = ( + props: Partial> = {} +) => + render( + + + + ); + +/** + * Flushes the enter animation and the work scheduled from its callback, so that + * assertions and snapshots run against the shown state. + */ +const settle = async () => { + await act(() => { + jest.runAllTimers(); + }); + await act(async () => { + await Promise.resolve(); + }); +}; + +describe('Snackbar', () => { + afterEach(() => { + jest.runOnlyPendingTimers(); + }); + + it('renders the message', async () => { + await renderSnackbar(); + await settle(); + + expect(screen.getByText('Snackbar content')).toBeOnTheScreen(); + }); + + it('renders nothing while it is not visible', async () => { + await renderSnackbar({ visible: false }); + + expect(screen.queryByText('Snackbar content')).toBeNull(); + }); + + it('renders a message with an action and a close icon button', async () => { + const tree = ( + await renderSnackbar({ + action: { label: 'Undo', onPress: jest.fn() }, + onIconPress: jest.fn(), + elevation: 3, + wrapperStyle: styles.wrapper, + messageStyle: styles.message, + style: styles.container, + testID: 'snackbar', + }) + ).toJSON(); + + expect(tree).toMatchSnapshot(); + }); + + it('dismisses itself when its action is pressed', async () => { + const onPress = jest.fn(); + const onDismiss = jest.fn(); + + await renderSnackbar({ onDismiss, action: { label: 'Undo', onPress } }); + await settle(); + + await userEvent.press(screen.getByRole('button', { name: 'Undo' })); + + expect(onPress).toHaveBeenCalledTimes(1); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('exposes the accessibility label of the action', async () => { + await renderSnackbar({ + action: { + label: 'Undo', + onPress: jest.fn(), + accessibilityLabel: 'Undo changes', + }, + }); + await settle(); + + expect( + screen.getByRole('button', { name: 'Undo changes' }) + ).toBeOnTheScreen(); + }); + + it('falls back to the action label when it has no accessibility label', async () => { + await renderSnackbar({ action: { label: 'Undo', onPress: jest.fn() } }); + await settle(); + + expect(screen.getByRole('button', { name: 'Undo' })).toBeOnTheScreen(); + }); + + it('renders a close icon button only when onIconPress is set', async () => { + const { unmount } = await renderSnackbar(); + await settle(); + + expect(screen.queryByRole('button', { name: 'Close icon' })).toBeNull(); + + await unmount(); + + await renderSnackbar({ onIconPress: jest.fn() }); + await settle(); + + expect( + screen.getByRole('button', { name: 'Close icon' }) + ).toBeOnTheScreen(); + }); + + it('invokes onIconPress when the close icon button is pressed', async () => { + const onIconPress = jest.fn(); + + await renderSnackbar({ onIconPress }); + await settle(); + + await userEvent.press(screen.getByRole('button', { name: 'Close icon' })); + + expect(onIconPress).toHaveBeenCalledTimes(1); + }); + + it('announces the message to assistive technology', async () => { + await renderSnackbar({ testID: 'snackbar' }); + await settle(); + + expect(screen.getByTestId('snackbar')).toHaveProp('aria-live', 'polite'); + }); + + it('invokes onDismiss once the duration has elapsed', async () => { + const onDismiss = jest.fn(); + + await renderSnackbar({ onDismiss, duration: 1000 }); + + expect(onDismiss).not.toHaveBeenCalled(); + + await settle(); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('does not dismiss on its own when the duration is infinite', async () => { + const onDismiss = jest.fn(); + + await renderSnackbar({ onDismiss }); + await settle(); + + expect(onDismiss).not.toHaveBeenCalled(); + expect(screen.getByText('Snackbar content')).toBeOnTheScreen(); + }); + + it('removes itself from the tree after being dismissed', async () => { + const { rerender } = await renderSnackbar(); + await settle(); + + await rerender( + + + + ); + + await settle(); + + expect(screen.queryByText('Snackbar content')).toBeNull(); + }); +}); diff --git a/src/components/__tests__/Snackbar/__snapshots__/Snackbar.test.tsx.snap b/src/components/__tests__/Snackbar/__snapshots__/Snackbar.test.tsx.snap new file mode 100644 index 0000000000..dcf7fa7f30 --- /dev/null +++ b/src/components/__tests__/Snackbar/__snapshots__/Snackbar.test.tsx.snap @@ -0,0 +1,532 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Snackbar renders a message with an action and a close icon button 1`] = ` +<> + + + + + + + Snackbar content + + + + + + + + Undo + + + + + + + + + close + + + + + + + + + +`; diff --git a/src/components/__tests__/__snapshots__/Snackbar.test.tsx.snap b/src/components/__tests__/__snapshots__/Snackbar.test.tsx.snap deleted file mode 100644 index 353efa0fed..0000000000 --- a/src/components/__tests__/__snapshots__/Snackbar.test.tsx.snap +++ /dev/null @@ -1,846 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`renders not visible snackbar with content wrapper but no actual content 1`] = `null`; - -exports[`renders snackbar with Text as a child 1`] = ` - - - - - - - Snackbar content - - - - - -`; - -exports[`renders snackbar with View & Text as a child 1`] = ` - - - - - - - - - Error Message which is veryyyyyyyyyyyy longggggggg Error Message which is veryyyyyyyyyyyy longggggggg - - - - - - -`; - -exports[`renders snackbar with action button 1`] = ` - - - - - Snackbar content - - - - - - - - Undo - - - - - - - -`; - -exports[`renders snackbar with content 1`] = ` - - - - - Snackbar content - - - -`; diff --git a/src/index.tsx b/src/index.tsx index 1807d2f249..c3fc2d758a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -41,7 +41,7 @@ export { default as Portal } from './components/Portal/Portal'; export { default as ProgressBar } from './components/ProgressBar'; export { default as RadioButton } from './components/RadioButton'; export { default as Searchbar } from './components/Searchbar'; -export { default as Snackbar } from './components/Snackbar'; +export { default as Snackbar } from './components/Snackbar/Snackbar'; export { default as Surface } from './components/Surface'; export { default as Switch } from './components/Switch/Switch'; export { default as Appbar } from './components/Appbar'; @@ -124,7 +124,10 @@ export type { Props as RadioButtonGroupProps } from './components/RadioButton/Ra 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 SnackbarProps } from './components/Snackbar'; +export type { + Props as SnackbarProps, + SnackbarAction, +} from './components/Snackbar/Snackbar'; export type { Props as SurfaceProps } from './components/Surface'; export type { Props as SwitchProps } from './components/Switch/Switch'; export type {