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 (
- *
- * {visible ? 'Hide' : 'Show'}
- * {
- * // 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 ? (
- {
- onPressAction?.(event);
- onDismiss();
- }}
- style={[styles.button, actionStyle]}
- textColor={buttonTextColor}
- compact={false}
- mode="text"
- theme={theme}
- {...actionProps}
- >
- {actionLabel}
-
- ) : 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) => (
+ {
+ action.onPress?.(event);
+ onDismiss();
+ }}
+ style={action.style}
+ testID={action.testID}
+ theme={theme}
+ textColor={color}
+ >
+ {action.label}
+
+);
+
+/**
+ * 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 (
+ *
+ * {visible ? 'Hide' : 'Show'}
+ * {
+ * // 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 {