diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js
index bc75f57c94..166a60e754 100644
--- a/docs/docusaurus.config.js
+++ b/docs/docusaurus.config.js
@@ -120,9 +120,9 @@ const config = {
DrawerSection: 'Drawer/DrawerSection',
},
FAB: {
- FAB: 'FAB/FAB',
- AnimatedFAB: 'FAB/AnimatedFAB',
- FABGroup: 'FAB/FABGroup',
+ FloatingActionButton: 'FAB/FloatingActionButton',
+ ExtendedFloatingActionButton: 'FAB/ExtendedFloatingActionButton',
+ FloatingActionButtonMenu: 'FAB/FloatingActionButtonMenu',
},
HelperText: { HelperText: 'HelperText/HelperText' },
IconButton: {
diff --git a/docs/src/components/BannerExample.tsx b/docs/src/components/BannerExample.tsx
index 59ec60634a..db77b1e4e1 100644
--- a/docs/src/components/BannerExample.tsx
+++ b/docs/src/components/BannerExample.tsx
@@ -8,7 +8,7 @@ import { useColorMode } from '@docusaurus/theme-common';
import {
Avatar,
Button,
- FAB,
+ FloatingActionButton,
DarkTheme,
LightTheme,
ProgressBar,
@@ -83,9 +83,9 @@ const BannerExample = () => {
- {}} />
- {}} />
- {}} />
+ {}} />
+ {}} />
+ {}} />
diff --git a/example/src/ExampleList.tsx b/example/src/ExampleList.tsx
index bdf5af7428..18c958a4bb 100644
--- a/example/src/ExampleList.tsx
+++ b/example/src/ExampleList.tsx
@@ -6,7 +6,6 @@ import { Divider, List, useTheme } from 'react-native-paper';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import ActivityIndicatorExample from './Examples/ActivityIndicatorExample';
-import AnimatedFABExample from './Examples/AnimatedFABExample';
import AppbarExample from './Examples/AppbarExample';
import AvatarExample from './Examples/AvatarExample';
import BadgeExample from './Examples/BadgeExample';
@@ -51,7 +50,6 @@ import TooltipExample from './Examples/TooltipExample';
import TouchableRippleExample from './Examples/TouchableRippleExample';
export const mainExamples = {
- AnimatedFAB: AnimatedFABExample,
ActivityIndicator: ActivityIndicatorExample,
Appbar: AppbarExample,
Avatar: AvatarExample,
diff --git a/example/src/Examples/ActivityIndicatorExample.tsx b/example/src/Examples/ActivityIndicatorExample.tsx
index e3e5e72e3c..193d9fdffc 100644
--- a/example/src/Examples/ActivityIndicatorExample.tsx
+++ b/example/src/Examples/ActivityIndicatorExample.tsx
@@ -1,7 +1,12 @@
import * as React from 'react';
import { StyleSheet, View } from 'react-native';
-import { ActivityIndicator, FAB, List, Palette } from 'react-native-paper';
+import {
+ ActivityIndicator,
+ FloatingActionButton,
+ List,
+ Palette,
+} from 'react-native-paper';
import ScreenWrapper from '../ScreenWrapper';
@@ -11,8 +16,7 @@ const ActivityIndicatorExample = () => {
return (
- setAnimating(!animating)}
/>
diff --git a/example/src/Examples/AnimatedFABExample/AnimatedFABExample.tsx b/example/src/Examples/AnimatedFABExample/AnimatedFABExample.tsx
deleted file mode 100644
index 8215092b79..0000000000
--- a/example/src/Examples/AnimatedFABExample/AnimatedFABExample.tsx
+++ /dev/null
@@ -1,204 +0,0 @@
-import * as React from 'react';
-import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
-import { Animated, FlatList, Platform, StyleSheet, View } from 'react-native';
-
-import Icon from '@react-native-vector-icons/material-design-icons';
-import { Avatar, Palette, Text, useTheme } from 'react-native-paper';
-
-import CustomFAB from './CustomFAB';
-import CustomFABControls, {
- Controls,
- initialControls,
-} from './CustomFABControls';
-import { animatedFABExampleData } from '../../../utils';
-type Item = {
- id: string;
- sender: string;
- header: string;
- message: string;
- initials: string;
- date: string;
- read: boolean;
- favorite: boolean;
- bgColor: string;
-};
-
-const AnimatedFABExample = () => {
- const { colors } = useTheme();
-
- const isIOS = Platform.OS === 'ios';
-
- const [extended, setExtended] = React.useState(true);
- const [visible, setVisible] = React.useState(true);
-
- const [controls, setControls] = React.useState(initialControls);
-
- const { current: velocity } = React.useRef(
- new Animated.Value(0)
- );
-
- const renderItem = React.useCallback(
- ({ item }: { item: Item }) => {
- return (
-
-
-
-
-
- {item.sender}
-
-
- {item.date}
-
-
-
-
-
-
- {item.header}
-
-
- {item.message}
-
-
-
- setVisible(!visible)}
- style={styles.icon}
- />
-
-
-
- );
- },
- [visible]
- );
-
- const onScroll = ({
- nativeEvent,
- }: NativeSyntheticEvent) => {
- const currentScrollPosition =
- Math.floor(nativeEvent?.contentOffset?.y) ?? 0;
-
- if (!isIOS) {
- return velocity.setValue(currentScrollPosition);
- }
-
- setExtended(currentScrollPosition <= 0);
- };
-
- const _keyExtractor = React.useCallback(
- (item: { id: string }) => item.id,
- []
- );
-
- const { animateFrom, iconMode } = controls;
-
- return (
- <>
-
-
-
-
- >
- );
-};
-
-AnimatedFABExample.title = 'Animated Floating Action Button';
-
-const styles = StyleSheet.create({
- container: {
- padding: 16,
- paddingBottom: 60,
- },
- avatar: {
- marginRight: 16,
- marginTop: 8,
- },
- flex: {
- flex: 1,
- },
- itemContainer: {
- marginBottom: 16,
- flexDirection: 'row',
- },
- itemTextContentContainer: {
- flexDirection: 'column',
- flex: 1,
- },
- itemHeaderContainer: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- },
- itemMessageContainer: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- flexGrow: 1,
- },
- read: {
- fontWeight: 'bold',
- },
- icon: {
- marginLeft: 16,
- alignSelf: 'flex-end',
- },
- date: {
- fontSize: 12,
- },
- header: {
- fontSize: 14,
- marginRight: 8,
- flex: 1,
- },
-});
-
-export default AnimatedFABExample;
diff --git a/example/src/Examples/AnimatedFABExample/CustomFAB.tsx b/example/src/Examples/AnimatedFABExample/CustomFAB.tsx
deleted file mode 100644
index 9130debf33..0000000000
--- a/example/src/Examples/AnimatedFABExample/CustomFAB.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import React from 'react';
-import { Animated, Platform, StyleSheet, ViewStyle } from 'react-native';
-
-import { AnimatedFAB } from 'react-native-paper';
-
-type CustomFABProps = {
- animatedValue: Animated.Value;
- visible: boolean;
- extended: boolean;
- label: string;
- animateFrom: 'left' | 'right';
- iconMode?: 'static' | 'dynamic';
- style?: ViewStyle;
-};
-
-const CustomFAB = ({
- animatedValue,
- visible,
- extended,
- label,
- animateFrom,
- style,
- iconMode,
-}: CustomFABProps) => {
- const [isExtended, setIsExtended] = React.useState(true);
-
- const isIOS = Platform.OS === 'ios';
-
- React.useEffect(() => {
- if (!isIOS) {
- animatedValue.addListener(({ value }: { value: number }) => {
- setIsExtended(value <= 0);
- });
- } else setIsExtended(extended);
- }, [animatedValue, extended, isIOS]);
-
- const fabStyle = { [animateFrom]: 16 };
-
- return (
- console.log('Pressed')}
- visible={visible}
- animateFrom={animateFrom}
- iconMode={iconMode}
- style={[styles.fabStyle, style, fabStyle]}
- />
- );
-};
-
-export default CustomFAB;
-
-const styles = StyleSheet.create({
- fabStyle: {
- bottom: 16,
- position: 'absolute',
- },
-});
diff --git a/example/src/Examples/AnimatedFABExample/CustomFABControls.tsx b/example/src/Examples/AnimatedFABExample/CustomFABControls.tsx
deleted file mode 100644
index 1cfaf1b84c..0000000000
--- a/example/src/Examples/AnimatedFABExample/CustomFABControls.tsx
+++ /dev/null
@@ -1,136 +0,0 @@
-import React from 'react';
-import { FlatList, ListRenderItemInfo, StyleSheet, View } from 'react-native';
-
-import { TouchableOpacity } from 'react-native-gesture-handler';
-import type {
- AnimatedFABAnimateFrom,
- AnimatedFABIconMode,
-} from 'react-native-paper';
-import { RadioButton, Text, useTheme } from 'react-native-paper';
-export type Controls = {
- iconMode: AnimatedFABIconMode;
- animateFrom: AnimatedFABAnimateFrom;
-};
-
-export const initialControls: Controls = {
- iconMode: 'static',
- animateFrom: 'right',
-};
-
-type Props = {
- controls: Controls;
- setControls(controls: React.SetStateAction): void;
-};
-
-type ControlValue = AnimatedFABIconMode | AnimatedFABAnimateFrom;
-
-type CustomControlProps = {
- name: string;
- options: ControlValue[];
- value: ControlValue;
- onChange(newValue: ControlValue): void;
-};
-
-const CustomControl = ({
- name,
- options,
- value,
- onChange,
-}: CustomControlProps) => {
- const _renderItem = React.useCallback(
- ({ item }: ListRenderItemInfo<(typeof options)[number]>) => {
- return (
- onChange(item)}
- style={styles.controlItem}
- >
- {item}
-
-
-
- );
- },
- [value, onChange]
- );
-
- const _keyExtractor = React.useCallback(
- (item: (typeof options)[number]) => item,
- []
- );
- return (
-
- {name}
-
-
-
- );
-};
-
-const CustomFABControls = ({
- setControls,
- controls: { animateFrom, iconMode },
-}: Props) => {
- const { colors } = useTheme();
-
- const setIconMode = (newIconMode: AnimatedFABIconMode) =>
- setControls((state) => ({ ...state, iconMode: newIconMode }));
-
- const setAnimateFrom = (newAnimateFrom: AnimatedFABAnimateFrom) =>
- setControls((state) => ({ ...state, animateFrom: newAnimateFrom }));
-
- return (
-
-
-
-
-
- );
-};
-
-export default CustomFABControls;
-
-const styles = StyleSheet.create({
- controlsWrapper: {
- paddingHorizontal: 16,
- },
- controlWrapper: {
- flexDirection: 'row',
- alignItems: 'center',
- },
- controlItemsList: {
- flex: 1,
- justifyContent: 'flex-end',
- },
- controlItem: {
- marginLeft: 16,
- flexDirection: 'row',
- alignItems: 'center',
- },
-});
diff --git a/example/src/Examples/AnimatedFABExample/index.ts b/example/src/Examples/AnimatedFABExample/index.ts
deleted file mode 100644
index 7561c57b50..0000000000
--- a/example/src/Examples/AnimatedFABExample/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from './AnimatedFABExample';
diff --git a/example/src/Examples/AppbarExample.tsx b/example/src/Examples/AppbarExample.tsx
index 846dd61182..c3d7622186 100644
--- a/example/src/Examples/AppbarExample.tsx
+++ b/example/src/Examples/AppbarExample.tsx
@@ -4,7 +4,7 @@ import { Platform, StyleSheet, View } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import {
Appbar,
- FAB,
+ FloatingActionButton,
List,
Palette,
RadioButton,
@@ -23,6 +23,7 @@ const MORE_ICON = Platform.OS === 'ios' ? 'dots-horizontal' : 'dots-vertical';
const MEDIUM_FAB_HEIGHT = 56;
const AppbarExample = () => {
+ // @ts-ignore
const navigation = useNavigation('Appbar');
const [showLeftIcon, setShowLeftIcon] = React.useState(true);
@@ -82,9 +83,7 @@ const AppbarExample = () => {
const renderFAB = () => {
return (
- {}}
style={[styles.fab, { top: (height - MEDIUM_FAB_HEIGHT) / 2 }]}
diff --git a/example/src/Examples/BannerExample.tsx b/example/src/Examples/BannerExample.tsx
index 85e7ec986d..0e0c236edb 100644
--- a/example/src/Examples/BannerExample.tsx
+++ b/example/src/Examples/BannerExample.tsx
@@ -8,7 +8,12 @@ import {
View,
} from 'react-native';
-import { Banner, FAB, Palette, useTheme } from 'react-native-paper';
+import {
+ Banner,
+ FloatingActionButton,
+ Palette,
+ useTheme,
+} from 'react-native-paper';
import ScreenWrapper from '../ScreenWrapper';
@@ -57,9 +62,8 @@ const BannerExample = () => {
))}
- setVisible(!visible)}
/>
diff --git a/example/src/Examples/FABExample.tsx b/example/src/Examples/FABExample.tsx
index 1ac542b371..806ff13836 100644
--- a/example/src/Examples/FABExample.tsx
+++ b/example/src/Examples/FABExample.tsx
@@ -1,168 +1,246 @@
import * as React from 'react';
-import { Alert, StyleSheet, View } from 'react-native';
+import {
+ FlatList,
+ NativeScrollEvent,
+ NativeSyntheticEvent,
+ ScrollView,
+ StyleSheet,
+ View,
+} from 'react-native';
-import { FAB, Portal, Text } from 'react-native-paper';
+import {
+ Chip,
+ Divider,
+ ExtendedFloatingActionButton,
+ FloatingActionButton,
+ FloatingActionButtonMenu,
+ FloatingActionButtonSize,
+ FloatingActionButtonVariant,
+ List,
+ Switch,
+ Text,
+ useTheme,
+} from 'react-native-paper';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
-import { isWeb } from '../../utils';
-import ScreenWrapper from '../ScreenWrapper';
+type FabType = 'icon' | 'extended' | 'extendedTransforming' | 'menu';
+type FabPosition = 'start' | 'center' | 'end';
-type FABVariant = 'primary' | 'secondary' | 'tertiary' | 'surface';
-type FABSize = 'small' | 'medium' | 'large';
-type FABMode = 'flat' | 'elevated';
+// Pixels of scroll change required to flip the transforming FAB. Avoids
+// flicker from sub-pixel scroll jitter at the top of the list.
+const SCROLL_DELTA_THRESHOLD = 4;
+
+const justifyContentByPosition = {
+ start: 'flex-start',
+ center: 'center',
+ end: 'flex-end',
+} as const satisfies Record;
+
+const variants: FloatingActionButtonVariant[] = [
+ 'primary',
+ 'secondary',
+ 'tertiary',
+ 'tonalPrimary',
+ 'tonalSecondary',
+ 'tonalTertiary',
+];
+
+const sizes: FloatingActionButtonSize[] = ['default', 'medium', 'large'];
+
+const types: FabType[] = ['icon', 'extended', 'extendedTransforming', 'menu'];
+
+const positions: FabPosition[] = ['start', 'center', 'end'];
+
+const rows = Array.from({ length: 40 }, (_, i) => ({
+ id: String(i + 1),
+ text: `Item ${i + 1}`,
+}));
+
+type ChipRowProps = {
+ label: string;
+ options: readonly T[];
+ value: T;
+ onChange: (value: T) => void;
+};
+
+const ChipRow = ({
+ label,
+ options,
+ value,
+ onChange,
+}: ChipRowProps) => (
+
+
+ {label}
+
+
+ {options.map((option) => (
+ onChange(option)}
+ >
+ {option}
+
+ ))}
+
+
+);
const FABExample = () => {
- const [visible, setVisible] = React.useState(true);
- const [toggleStackOnLongPress, setToggleStackOnLongPress] =
- React.useState(false);
- const [open, setOpen] = React.useState(false);
+ const { colors } = useTheme();
+ const insets = useSafeAreaInsets();
+
+ const [variant, setVariant] =
+ React.useState('tonalPrimary');
+ const [size, setSize] = React.useState('medium');
+ const [type, setType] = React.useState('icon');
+ const [position, setPosition] = React.useState('end');
+ const [showFab, setShowFab] = React.useState(true);
+ const [transformingExpanded, setTransformingExpanded] = React.useState(true);
+ const [menuExpanded, setMenuExpanded] = React.useState(false);
+ const lastScrollY = React.useRef(0);
- const variants = ['primary', 'secondary', 'tertiary', 'surface'];
- const sizes = ['small', 'medium', 'large'];
- const modes = ['flat', 'elevated'];
+ const fabPadding = 16;
+
+ const renderItem = React.useCallback(
+ ({ item }: { item: (typeof rows)[number] }) => (
+
+ {item.text}
+
+ ),
+ []
+ );
+
+ const onScroll = React.useCallback(
+ ({ nativeEvent }: NativeSyntheticEvent) => {
+ const y = nativeEvent.contentOffset.y;
+ const delta = y - lastScrollY.current;
+ if (Math.abs(delta) < SCROLL_DELTA_THRESHOLD) {
+ return;
+ }
+ lastScrollY.current = y;
+ if (y <= 0) {
+ setTransformingExpanded(true);
+ } else if (delta > 0) {
+ setTransformingExpanded(false);
+ } else {
+ setTransformingExpanded(true);
+ }
+ },
+ []
+ );
return (
-
-
- setVisible(!visible)}
+
+
+
+
+
+
+ (
+
+
+
+ )}
+ onPress={() => setShowFab((v) => !v)}
+ />
+
-
- {variants.map((variant) => (
-
- {}}
- visible={visible}
- variant={variant as FABVariant}
- />
- {visible && {variant}}
-
- ))}
-
-
- {sizes.map((size) => (
-
- {}}
- visible={visible}
- size={size as FABSize}
- />
- {visible && {size}}
-
- ))}
+ item.id}
+ contentContainerStyle={[
+ styles.listContent,
+ { paddingBottom: insets.bottom + fabPadding + 96 },
+ ]}
+ onScroll={type === 'extendedTransforming' ? onScroll : undefined}
+ scrollEventThrottle={16}
+ />
+
+ {type === 'icon' && (
+ {}}
+ />
+ )}
+ {(type === 'extended' || type === 'extendedTransforming') && (
+ {}}
+ />
+ )}
-
- {modes.map((mode) => (
-
- setMenuExpanded(false)}
+ horizontalAlignment={position}
+ button={
+ {}}
- visible={visible}
- mode={mode as FABMode}
+ variant={variant}
+ size={size}
+ visible={showFab}
+ onPress={() => setMenuExpanded(true)}
/>
- {visible && {mode}}
-
- ))}
-
-
- {}}
- visible={visible}
- />
- {}}
- visible={visible}
- />
- {}}
- visible={visible}
- />
- {}}
- visible={visible}
- uppercase
- />
- {}}
- visible={visible}
- loading
- />
-
- {} },
- { icon: 'star', label: 'Star', onPress: () => {} },
- { icon: 'email', label: 'Email', onPress: () => {} },
- {
- icon: 'bell',
- label: 'Remind',
- onPress: () => {},
- size: 'small',
- },
- {
- icon: toggleStackOnLongPress
- ? 'gesture-tap'
- : 'gesture-tap-hold',
- label: toggleStackOnLongPress
- ? 'Toggle on Press'
- : 'Toggle on Long Press',
- onPress: () => {
- setToggleStackOnLongPress(!toggleStackOnLongPress);
- },
- },
- ]}
- enableLongPressWhenStackOpened
- onStateChange={({ open }: { open: boolean }) => setOpen(open)}
- onPress={() => {
- if (toggleStackOnLongPress) {
- isWeb ? alert('Fab is Pressed') : Alert.alert('Fab is Pressed');
- // do something on press when the speed dial is closed
- } else if (open) {
- isWeb ? alert('Fab is Pressed') : Alert.alert('Fab is Pressed');
- // do something if the speed dial is open
- }
- }}
- onLongPress={() => {
- if (!toggleStackOnLongPress || open) {
- isWeb
- ? alert('Fab is Long Pressed')
- : Alert.alert('Fab is Long Pressed');
- // do something if the speed dial is open
- }
- }}
- visible={visible}
+ }
+ >
+ {}}
/>
-
-
-
+ {}}
+ />
+ {}}
+ />
+
+ ) : null}
+
);
};
@@ -170,26 +248,32 @@ FABExample.title = 'Floating Action Button';
const styles = StyleSheet.create({
container: {
- padding: 4,
+ flex: 1,
},
- row: {
- marginBottom: 8,
- flexDirection: 'row',
- justifyContent: 'center',
- alignItems: 'center',
+ controls: {
+ paddingTop: 8,
+ paddingBottom: 8,
},
- column: {
- justifyContent: 'center',
- alignItems: 'center',
- marginBottom: 8,
+ chipRow: {
+ paddingVertical: 4,
},
- fab: {
- margin: 8,
+ chipRowLabel: {
+ paddingHorizontal: 16,
+ paddingBottom: 6,
},
- fabVariant: {
- flex: 1,
- justifyContent: 'space-between',
- alignItems: 'center',
+ chipRowContent: {
+ paddingHorizontal: 16,
+ gap: 8,
+ },
+ listContent: {
+ paddingHorizontal: 16,
+ },
+ listItem: {
+ paddingVertical: 12,
+ },
+ fabContainer: {
+ position: 'absolute',
+ flexDirection: 'row',
},
});
diff --git a/example/src/Examples/TeamDetails.tsx b/example/src/Examples/TeamDetails.tsx
index 5970274f31..bd4bdb6dd5 100644
--- a/example/src/Examples/TeamDetails.tsx
+++ b/example/src/Examples/TeamDetails.tsx
@@ -18,7 +18,7 @@ import {
Chip,
Divider,
IconButton,
- FAB,
+ FloatingActionButton,
PaperProvider,
} from 'react-native-paper';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -116,7 +116,12 @@ const News = () => {
- {}} visible style={styles.fab} />
+ {}}
+ visible
+ style={styles.fab}
+ />
>
);
};
diff --git a/example/src/Examples/TooltipExample.tsx b/example/src/Examples/TooltipExample.tsx
index ebe86e74f2..dc942bf340 100644
--- a/example/src/Examples/TooltipExample.tsx
+++ b/example/src/Examples/TooltipExample.tsx
@@ -7,7 +7,7 @@ import {
Avatar,
Banner,
Chip,
- FAB,
+ FloatingActionButton,
IconButton,
List,
ToggleButton,
@@ -149,7 +149,7 @@ const TooltipExample = () => {
- {}} />
+ {}} />
>
diff --git a/src/babel/__fixtures__/rewrite-imports/code.js b/src/babel/__fixtures__/rewrite-imports/code.js
index f1253a5e08..868e917133 100644
--- a/src/babel/__fixtures__/rewrite-imports/code.js
+++ b/src/babel/__fixtures__/rewrite-imports/code.js
@@ -3,7 +3,7 @@ import {
PaperProvider,
BottomNavigation,
Button,
- FAB,
+ FloatingActionButton,
Appbar,
Palette,
NonExistent,
diff --git a/src/babel/__fixtures__/rewrite-imports/output.js b/src/babel/__fixtures__/rewrite-imports/output.js
index bbe342ad0d..b56be3496e 100644
--- a/src/babel/__fixtures__/rewrite-imports/output.js
+++ b/src/babel/__fixtures__/rewrite-imports/output.js
@@ -2,7 +2,7 @@
import PaperProvider from "react-native-paper/lib/module/core/PaperProvider";
import BottomNavigation from "react-native-paper/lib/module/components/BottomNavigation/BottomNavigation";
import Button from "react-native-paper/lib/module/components/Button/Button";
-import FAB from "react-native-paper/lib/module/components/FAB";
+import FloatingActionButton from "react-native-paper/lib/module/components/FAB/FloatingActionButton";
import Appbar from "react-native-paper/lib/module/components/Appbar";
import { Palette } from "react-native-paper/lib/module/theme/tokens";
import { NonExistent, NonExistentSecond as Stuff, LightTheme } from "react-native-paper/lib/module/index.js";
diff --git a/src/components/FAB/AnimatedFAB.tsx b/src/components/FAB/AnimatedFAB.tsx
deleted file mode 100644
index 5ca2348614..0000000000
--- a/src/components/FAB/AnimatedFAB.tsx
+++ /dev/null
@@ -1,594 +0,0 @@
-import * as React from 'react';
-import type {
- AccessibilityState,
- NativeSyntheticEvent,
- PressableAndroidRippleConfig,
- TextLayoutEventData,
-} from 'react-native';
-import {
- Animated,
- Easing,
- GestureResponderEvent,
- Platform,
- ScrollView,
- StyleProp,
- StyleSheet,
- View,
- ViewStyle,
- Text,
-} from 'react-native';
-
-import { getCombinedStyles, getFABColors, getLabelSizeWeb } from './utils';
-import { useLocale } from '../../core/locale';
-import { useInternalTheme } from '../../core/theming';
-import type { $Omit, $RemoveChildren, ThemeProp } from '../../types';
-import type { IconSource } from '../Icon';
-import Icon from '../Icon';
-import Surface from '../Surface';
-import TouchableRipple, {
- Props as TouchableRippleProps,
-} from '../TouchableRipple/TouchableRipple';
-import AnimatedText from '../Typography/AnimatedText';
-
-export type AnimatedFABIconMode = 'static' | 'dynamic';
-export type AnimatedFABAnimateFrom = 'left' | 'right';
-
-export type Props = $Omit<$RemoveChildren, 'mode'> & {
- /**
- * Icon to display for the `FAB`.
- */
- icon: IconSource;
- /**
- * Label for extended `FAB`.
- */
- label: string;
- /**
- * Make the label text uppercased.
- */
- uppercase?: boolean;
- /**
- * Type of background drawabale to display the feedback (Android).
- * https://reactnative.dev/docs/pressable#rippleconfig
- */
- background?: PressableAndroidRippleConfig;
- /**
- * Accessibility label for the FAB. This is read by the screen reader when the user taps the FAB.
- * Uses `label` by default if specified.
- */
- accessibilityLabel?: string;
- /**
- * Accessibility state for the FAB. This is read by the screen reader when the user taps the FAB.
- */
- accessibilityState?: AccessibilityState;
- /**
- * Custom color for the icon and label of the `FAB`.
- */
- color?: string;
- /**
- * Whether `FAB` is currently visible.
- */
- visible?: boolean;
- /**
- * Function to execute on press.
- */
- onPress?: (e: GestureResponderEvent) => void;
- /**
- * Function to execute on long press.
- */
- onLongPress?: (e: GestureResponderEvent) => void;
- /**
- * The number of milliseconds a user must touch the element before executing `onLongPress`.
- */
- delayLongPress?: number;
- /**
- * Whether icon should be translated to the end of extended `FAB` or be static and stay in the same place. The default value is `dynamic`.
- */
- iconMode?: AnimatedFABIconMode;
- /**
- * Indicates from which direction animation should be performed. The default value is `right`.
- */
- animateFrom?: AnimatedFABAnimateFrom;
- /**
- * Whether `FAB` should start animation to extend.
- */
- extended: boolean;
- /**
- * Specifies the largest possible scale a label font can reach.
- */
- labelMaxFontSizeMultiplier?: number;
- /**
- * @supported Available in v5.x with theme version 3
- *
- * Color mappings variant for combinations of container and icon colors.
- */
- variant?: 'primary' | 'secondary' | 'tertiary' | 'surface';
- style?: Animated.WithAnimatedValue>;
- /**
- * Sets additional distance outside of element in which a press can be detected.
- */
- hitSlop?: TouchableRippleProps['hitSlop'];
- /**
- * @optional
- */
- theme?: ThemeProp;
- /**
- * TestID used for testing purposes
- */
- testID?: string;
-};
-
-const SIZE = 56;
-const SHADOW_LAYER_Z_INDEX = 1;
-const CONTENT_LAYER_Z_INDEX = 2;
-
-/**
- * An animated, extending horizontally floating action button represents the primary action in an application.
- *
- * ## Usage
- * ```js
- * import React from 'react';
- * import {
- * StyleProp,
- * ViewStyle,
- * Animated,
- * StyleSheet,
- * Platform,
- * ScrollView,
- * Text,
- * SafeAreaView,
- * I18nManager,
- * } from 'react-native';
- * import { AnimatedFAB } from 'react-native-paper';
- *
- * const MyComponent = ({
- * animatedValue,
- * visible,
- * extended,
- * label,
- * animateFrom,
- * style,
- * iconMode,
- * }) => {
- * const [isExtended, setIsExtended] = React.useState(true);
- *
- * const isIOS = Platform.OS === 'ios';
- *
- * const onScroll = ({ nativeEvent }) => {
- * const currentScrollPosition =
- * Math.floor(nativeEvent?.contentOffset?.y) ?? 0;
- *
- * setIsExtended(currentScrollPosition <= 0);
- * };
- *
- * const fabStyle = { [animateFrom]: 16 };
- *
- * return (
- *
- *
- * {[...new Array(100).keys()].map((_, i) => (
- * {i}
- * ))}
- *
- * console.log('Pressed')}
- * visible={visible}
- * animateFrom={'right'}
- * iconMode={'static'}
- * style={[styles.fabStyle, style, fabStyle]}
- * />
- *
- * );
- * };
- *
- * export default MyComponent;
- *
- * const styles = StyleSheet.create({
- * container: {
- * flexGrow: 1,
- * },
- * fabStyle: {
- * bottom: 16,
- * right: 16,
- * position: 'absolute',
- * },
- * });
- * ```
- */
-const AnimatedFAB = ({
- icon,
- label,
- background,
- accessibilityLabel = label,
- accessibilityState,
- color: customColor,
- onPress,
- onLongPress,
- delayLongPress,
- theme: themeOverrides,
- style,
- visible = true,
- uppercase: uppercaseProp,
- testID = 'animated-fab',
- animateFrom = 'right',
- extended = false,
- iconMode = 'dynamic',
- variant = 'primary',
- labelMaxFontSizeMultiplier,
- hitSlop,
- ...rest
-}: Props) => {
- const theme = useInternalTheme(themeOverrides);
- const { direction } = useLocale();
- const uppercase: boolean = uppercaseProp ?? false;
- const isIOS = Platform.OS === 'ios';
- const isWeb = Platform.OS === 'web';
- const isAnimatedFromRight = animateFrom === 'right';
- const isIconStatic = iconMode === 'static';
- const isRTL = direction === 'rtl';
- const labelRef = React.useRef(null);
- const { current: visibility } = React.useRef(
- new Animated.Value(visible ? 1 : 0)
- );
- const { current: animFAB } = React.useRef(
- new Animated.Value(0)
- );
- const { animation } = theme;
- const { scale } = animation;
-
- const labelSize = isWeb ? getLabelSizeWeb(labelRef) : null;
- const [textWidth, setTextWidth] = React.useState(
- labelSize?.width ?? 0
- );
- const [textHeight, setTextHeight] = React.useState(
- labelSize?.height ?? 0
- );
-
- const borderRadius = SIZE / 3.5;
-
- React.useEffect(() => {
- if (!isWeb) {
- return;
- }
-
- const updateTextSize = () => {
- if (labelRef.current) {
- const labelSize = getLabelSizeWeb(labelRef);
-
- if (labelSize) {
- setTextHeight(labelSize.height ?? 0);
- setTextWidth(labelSize.width ?? 0);
- }
- }
- };
-
- updateTextSize();
- window.addEventListener('resize', updateTextSize);
-
- return () => {
- if (!isWeb) {
- return;
- }
-
- window.removeEventListener('resize', updateTextSize);
- };
- }, [isWeb]);
-
- React.useEffect(() => {
- if (visible) {
- Animated.timing(visibility, {
- toValue: 1,
- duration: 200 * scale,
- useNativeDriver: true,
- }).start();
- } else {
- Animated.timing(visibility, {
- toValue: 0,
- duration: 150 * scale,
- useNativeDriver: true,
- }).start();
- }
- }, [visible, scale, visibility]);
-
- const { backgroundColor: customBackgroundColor, ...restStyle } =
- (StyleSheet.flatten(style) || {}) as ViewStyle;
-
- const { backgroundColor, foregroundColor } = getFABColors({
- theme,
- variant,
- customColor,
- customBackgroundColor,
- });
-
- const extendedWidth = textWidth + SIZE + borderRadius;
-
- const distance = isAnimatedFromRight
- ? -textWidth - borderRadius
- : textWidth + borderRadius;
-
- React.useEffect(() => {
- Animated.timing(animFAB, {
- toValue: !extended ? 0 : distance,
- duration: 150 * scale,
- useNativeDriver: true,
- easing: Easing.linear,
- }).start();
- }, [animFAB, scale, distance, extended]);
-
- const onTextLayout = ({
- nativeEvent,
- }: NativeSyntheticEvent) => {
- const currentWidth = Math.ceil(nativeEvent.lines[0]?.width ?? 0);
- const currentHeight = Math.ceil(nativeEvent.lines[0]?.height ?? 0);
-
- if (currentWidth !== textWidth || currentHeight !== textHeight) {
- setTextHeight(currentHeight);
-
- if (isIOS) {
- return setTextWidth(currentWidth - 12);
- }
-
- setTextWidth(currentWidth);
- }
- };
-
- const propForDirection = (right: T[]): T[] => {
- if (isAnimatedFromRight) {
- return right;
- }
-
- return right.reverse();
- };
-
- const combinedStyles = getCombinedStyles({
- isAnimatedFromRight,
- isIconStatic,
- isRTL,
- distance,
- animFAB,
- });
-
- const font = theme.fonts.labelLarge;
-
- const textStyle = {
- color: foregroundColor,
- ...font,
- };
-
- const md3Elevation = !isIOS ? 0 : 3;
-
- const shadowStyle = styles.shadow;
- const baseStyle = [StyleSheet.absoluteFill, shadowStyle];
-
- const newAccessibilityState = { ...accessibilityState };
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {label}
-
-
-
- {!isIOS && (
- // Method `onTextLayout` on Android returns sizes of text visible on the screen,
- // however during render the text in `FAB` isn't fully visible. In order to get
- // proper text measurements there is a need to additionaly render that text, but
- // wrapped in absolutely positioned `ScrollView` which height is 0.
-
-
- {label}
-
-
- )}
-
- );
-};
-
-const styles = StyleSheet.create({
- standard: {
- height: SIZE,
- },
- // eslint-disable-next-line react-native/no-color-literals
- container: {
- position: 'absolute',
- backgroundColor: 'transparent',
- },
- innerWrapper: {
- flexDirection: 'row',
- overflow: 'hidden',
- ...Platform.select({
- android: {
- zIndex: CONTENT_LAYER_Z_INDEX,
- },
- }),
- },
- shadowWrapper: {
- elevation: 0,
- ...Platform.select({
- android: {
- zIndex: SHADOW_LAYER_Z_INDEX,
- },
- }),
- },
- shadow: {
- elevation: 3,
- },
- iconWrapper: {
- alignItems: 'center',
- justifyContent: 'center',
- position: 'absolute',
- height: SIZE,
- width: SIZE,
- },
- label: {
- position: 'absolute',
- },
- uppercaseLabel: {
- textTransform: 'uppercase',
- },
- textPlaceholderContainer: {
- height: 0,
- position: 'absolute',
- },
-});
-
-export default AnimatedFAB;
diff --git a/src/components/FAB/ExtendedFloatingActionButton.tsx b/src/components/FAB/ExtendedFloatingActionButton.tsx
new file mode 100644
index 0000000000..33e9ec5c14
--- /dev/null
+++ b/src/components/FAB/ExtendedFloatingActionButton.tsx
@@ -0,0 +1,273 @@
+import * as React from 'react';
+import {
+ AccessibilityState,
+ GestureResponderEvent,
+ Platform,
+ PressableAndroidRippleConfig,
+ StyleProp,
+ Text as NativeText,
+ TextLayoutEvent,
+ View,
+ ViewStyle,
+} from 'react-native';
+
+import {
+ useAnimatedStyle,
+ useSharedValue,
+ withSpring,
+} from 'react-native-reanimated';
+
+import FabShell from './FabShell';
+import {
+ FloatingActionButtonSize,
+ FloatingActionButtonVariant,
+} from './tokens';
+import { getDimensions, getLabelSizeWeb } from './utils';
+import { useInternalTheme } from '../../core/theming';
+import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext';
+import { toRawSpring } from '../../theme/tokens/sys/motion';
+import type { ThemeProp } from '../../types';
+import { forwardRef } from '../../utils/forwardRef';
+import type { IconSource } from '../Icon';
+
+export type Props = {
+ /**
+ * Icon to display inside the FAB.
+ */
+ icon: IconSource;
+ /**
+ * Label rendered next to the icon when expanded.
+ */
+ label: string;
+ /**
+ * Role-color preset. Defaults to `tonalPrimary`.
+ */
+ variant?: FloatingActionButtonVariant;
+ /**
+ * Spec size. Defaults to `default`.
+ */
+ size?: FloatingActionButtonSize;
+ /**
+ * Whether the FAB is expanded (icon + label) or collapsed (icon only). The
+ * width and label opacity animate per the MD3 Expressive spec on change.
+ */
+ expanded: boolean;
+ /**
+ * Whether the FAB is currently visible. Toggling animates the spec'd enter
+ * and exit (scale + alpha) on the FAB itself.
+ */
+ visible?: boolean;
+ /**
+ * Function to execute on press.
+ */
+ onPress?: (e: GestureResponderEvent) => void;
+ /**
+ * Accessibility label. Falls back to `label` if unset.
+ */
+ accessibilityLabel?: string;
+ /**
+ * Accessibility state forwarded to the underlying button.
+ */
+ accessibilityState?: AccessibilityState;
+ /**
+ * Specifies the largest possible scale a label font can reach.
+ */
+ labelMaxFontSizeMultiplier?: number;
+ /**
+ * Type of background drawable to display the feedback (Android).
+ * https://reactnative.dev/docs/pressable#rippleconfig
+ */
+ background?: PressableAndroidRippleConfig;
+ /**
+ * Style for positioning the FAB. The visual treatment (size, shape, color)
+ * is driven by `variant` and `size`.
+ */
+ style?: StyleProp;
+ /**
+ * TestID used for testing purposes.
+ */
+ testID?: string;
+ /**
+ * @optional
+ */
+ theme?: ThemeProp;
+ ref?: React.RefObject;
+};
+
+/**
+ * An extended floating action button represents the primary action on a screen
+ * and shows a label next to the icon. Animates between expanded (icon + label)
+ * and collapsed (icon only) states.
+ *
+ * ## Usage
+ * ```js
+ * import * as React from 'react';
+ * import { StyleSheet } from 'react-native';
+ * import { ExtendedFloatingActionButton } from 'react-native-paper';
+ *
+ * const MyComponent = () => {
+ * const [expanded, setExpanded] = React.useState(true);
+ *
+ * return (
+ * setExpanded((v) => !v)}
+ * style={styles.fab}
+ * />
+ * );
+ * };
+ *
+ * const styles = StyleSheet.create({
+ * fab: {
+ * position: 'absolute',
+ * margin: 16,
+ * left: 0,
+ * bottom: 0,
+ * },
+ * });
+ *
+ * export default MyComponent;
+ * ```
+ */
+const ExtendedFloatingActionButton = forwardRef(
+ (
+ {
+ icon,
+ label,
+ variant = 'tonalPrimary',
+ size = 'default',
+ expanded,
+ visible = true,
+ onPress,
+ accessibilityLabel = label,
+ accessibilityState,
+ labelMaxFontSizeMultiplier,
+ background,
+ style,
+ testID = 'extended-floating-action-button',
+ theme: themeOverrides,
+ },
+ ref
+ ) => {
+ const theme = useInternalTheme(themeOverrides);
+ const reduceMotion = useReduceMotion();
+ const isWeb = Platform.OS === 'web';
+
+ const dimensions = React.useMemo(
+ () => getDimensions({ theme, size }),
+ [theme, size]
+ );
+
+ const labelRef = React.useRef(null);
+ const initialLabelSize = isWeb ? getLabelSizeWeb(labelRef) : null;
+ const [labelWidth, setLabelWidth] = React.useState(
+ initialLabelSize?.width ?? 0
+ );
+
+ const collapsedWidth = dimensions.width;
+ const expandedWidth =
+ dimensions.leading +
+ dimensions.iconSize +
+ dimensions.iconLabelGap +
+ labelWidth +
+ dimensions.trailing;
+
+ const widthValue = useSharedValue(
+ expanded ? expandedWidth : collapsedWidth
+ );
+ const labelOpacity = useSharedValue(expanded ? 1 : 0);
+
+ React.useEffect(() => {
+ if (!isWeb) {
+ return;
+ }
+ const updateLabelSize = () => {
+ if (labelRef.current) {
+ const measured = getLabelSizeWeb(labelRef);
+ if (measured) {
+ setLabelWidth(measured.width);
+ }
+ }
+ };
+ updateLabelSize();
+ window.addEventListener('resize', updateLabelSize);
+ return () => {
+ window.removeEventListener('resize', updateLabelSize);
+ };
+ }, [isWeb, label]);
+
+ React.useEffect(() => {
+ const targetWidth = expanded ? expandedWidth : collapsedWidth;
+ const targetOpacity = expanded ? 1 : 0;
+ if (reduceMotion) {
+ widthValue.value = targetWidth;
+ labelOpacity.value = targetOpacity;
+ return;
+ }
+ const widthSpring = toRawSpring(
+ expanded
+ ? theme.motion.spring.fast.spatial
+ : theme.motion.spring.default.spatial
+ );
+ const opacitySpring = toRawSpring(
+ expanded
+ ? theme.motion.spring.default.effects
+ : theme.motion.spring.fast.effects
+ );
+ widthValue.value = withSpring(targetWidth, widthSpring);
+ labelOpacity.value = withSpring(targetOpacity, opacitySpring);
+ }, [
+ expanded,
+ expandedWidth,
+ collapsedWidth,
+ theme,
+ reduceMotion,
+ widthValue,
+ labelOpacity,
+ ]);
+
+ const labelAnimatedStyle = useAnimatedStyle(() => ({
+ opacity: labelOpacity.value,
+ }));
+
+ const onTextLayout = ({ nativeEvent }: TextLayoutEvent) => {
+ const measured = Math.ceil(nativeEvent.lines[0]?.width ?? 0);
+ if (measured !== labelWidth) {
+ setLabelWidth(measured);
+ }
+ };
+
+ return (
+
+ );
+ }
+);
+
+export default ExtendedFloatingActionButton;
+
+// @component-docs ignore-next-line
+export { ExtendedFloatingActionButton };
diff --git a/src/components/FAB/FAB.tsx b/src/components/FAB/FAB.tsx
deleted file mode 100644
index 1926449f28..0000000000
--- a/src/components/FAB/FAB.tsx
+++ /dev/null
@@ -1,342 +0,0 @@
-import * as React from 'react';
-import {
- AccessibilityState,
- Animated,
- ColorValue,
- GestureResponderEvent,
- PressableAndroidRippleConfig,
- StyleProp,
- StyleSheet,
- View,
- ViewStyle,
-} from 'react-native';
-
-import { getExtendedFabStyle, getFABColors, getFabStyle } from './utils';
-import { useInternalTheme } from '../../core/theming';
-import type { $Omit, $RemoveChildren, ThemeProp } from '../../types';
-import { forwardRef } from '../../utils/forwardRef';
-import ActivityIndicator from '../ActivityIndicator';
-import CrossFadeIcon from '../CrossFadeIcon';
-import Icon, { IconSource } from '../Icon';
-import Surface from '../Surface';
-import TouchableRipple from '../TouchableRipple/TouchableRipple';
-import Text from '../Typography/Text';
-
-type FABSize = 'small' | 'medium' | 'large';
-
-type FABMode = 'flat' | 'elevated';
-
-type IconOrLabel =
- | {
- icon: IconSource;
- label?: string;
- }
- | {
- icon?: IconSource;
- label: string;
- };
-
-export type Props = $Omit<$RemoveChildren, 'mode'> & {
- // For `icon` and `label` props their types are duplicated due to the generation of documentation.
- // Appropriate type for them is `IconOrLabel` contains the both union and intersection types.
- /**
- * Icon to display for the `FAB`. It's optional only if `label` is defined.
- */
- icon?: IconSource;
- /**
- * Optional label for extended `FAB`. It's optional only if `icon` is defined.
- */
- label?: string;
- /**
- * Make the label text uppercased.
- */
- uppercase?: boolean;
- /**
- * Type of background drawabale to display the feedback (Android).
- * https://reactnative.dev/docs/pressable#rippleconfig
- */
- background?: PressableAndroidRippleConfig;
- /**
- * Accessibility label for the FAB. This is read by the screen reader when the user taps the FAB.
- * Uses `label` by default if specified.
- */
- accessibilityLabel?: string;
- /**
- * Accessibility state for the FAB. This is read by the screen reader when the user taps the FAB.
- */
- accessibilityState?: AccessibilityState;
- /**
- * Whether an icon change is animated.
- */
- animated?: boolean;
- /**
- * Custom color for the icon and label of the `FAB`.
- */
- color?: ColorValue;
- /**
- * Whether `FAB` is currently visible.
- */
- visible?: boolean;
- /**
- * Whether to show a loading indicator.
- */
- loading?: boolean;
- /**
- * Function to execute on press.
- */
- onPress?: (e: GestureResponderEvent) => void;
- /**
- * Function to execute on long press.
- */
- onLongPress?: (e: GestureResponderEvent) => void;
- /**
- * The number of milliseconds a user must touch the element before executing `onLongPress`.
- */
- delayLongPress?: number;
- /**
- * @supported Available in v5.x with theme version 3
- *
- * Size of the `FAB`.
- * - `small` - FAB with small height (40).
- * - `medium` - FAB with default medium height (56).
- * - `large` - FAB with large height (96).
- */
- size?: FABSize;
- /**
- * Custom size for the `FAB`. This prop takes precedence over size prop
- */
- customSize?: number;
- /**
- * @supported Available in v5.x with theme version 3
- *
- * Mode of the `FAB`. You can change the mode to adjust the the shadow:
- * - `flat` - button without a shadow.
- * - `elevated` - button with a shadow.
- */
- mode?: FABMode;
- /**
- * @supported Available in v5.x with theme version 3
- *
- * Color mappings variant for combinations of container and icon colors.
- */
- variant?: 'primary' | 'secondary' | 'tertiary' | 'surface';
- /**
- * Specifies the largest possible scale a label font can reach.
- */
- labelMaxFontSizeMultiplier?: number;
- style?: Animated.WithAnimatedValue>;
- /**
- * @optional
- */
- theme?: ThemeProp;
- /**
- * TestID used for testing purposes
- */
- testID?: string;
- ref?: React.RefObject;
-} & IconOrLabel;
-
-/**
- * A floating action button represents the primary action on a screen. It appears in front of all screen content.
- *
- * ## Usage
- * ```js
- * import * as React from 'react';
- * import { StyleSheet } from 'react-native';
- * import { FAB } from 'react-native-paper';
- *
- * const MyComponent = () => (
- * console.log('Pressed')}
- * />
- * );
- *
- * const styles = StyleSheet.create({
- * fab: {
- * position: 'absolute',
- * margin: 16,
- * right: 0,
- * bottom: 0,
- * },
- * })
- *
- * export default MyComponent;
- * ```
- */
-const FAB = forwardRef(
- (
- {
- icon,
- label,
- background,
- accessibilityLabel = label,
- accessibilityState,
- animated = true,
- color: customColor,
- onPress,
- onLongPress,
- delayLongPress,
- theme: themeOverrides,
- style,
- visible = true,
- uppercase: uppercaseProp,
- loading,
- testID = 'fab',
- size = 'medium',
- customSize,
- mode = 'elevated',
- variant = 'primary',
- labelMaxFontSizeMultiplier,
- ...rest
- }: Props,
- ref
- ) => {
- const theme = useInternalTheme(themeOverrides);
- const uppercase = uppercaseProp ?? false;
- const { current: visibility } = React.useRef(
- new Animated.Value(visible ? 1 : 0)
- );
- const { animation } = theme;
- const { scale } = animation;
-
- React.useEffect(() => {
- if (visible) {
- Animated.timing(visibility, {
- toValue: 1,
- duration: 200 * scale,
- useNativeDriver: true,
- }).start();
- } else {
- Animated.timing(visibility, {
- toValue: 0,
- duration: 150 * scale,
- useNativeDriver: true,
- }).start();
- }
- }, [visible, scale, visibility]);
-
- const IconComponent = animated ? CrossFadeIcon : Icon;
-
- const fabStyle = getFabStyle({ customSize, size, theme });
-
- const {
- borderRadius = fabStyle.borderRadius,
- backgroundColor: customBackgroundColor,
- } = (StyleSheet.flatten(style) || {}) as ViewStyle;
-
- const { backgroundColor, foregroundColor } = getFABColors({
- theme,
- variant,
- customColor,
- customBackgroundColor,
- });
-
- const isLargeSize = size === 'large';
- const isFlatMode = mode === 'flat';
- const iconSize = isLargeSize ? 36 : 24;
- const loadingIndicatorSize = isLargeSize ? 24 : 18;
- const font = theme.fonts.labelLarge;
-
- const extendedStyle = getExtendedFabStyle({ customSize, theme });
- const textStyle = {
- color: foregroundColor,
- ...font,
- };
-
- const md3Elevation = isFlatMode ? 0 : 3;
-
- return (
-
-
-
- {icon && loading !== true ? (
-
- ) : null}
- {loading ? (
-
- ) : null}
- {label ? (
-
- {label}
-
- ) : null}
-
-
-
- );
- }
-);
-
-const styles = StyleSheet.create({
- content: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- },
- label: {
- marginHorizontal: 8,
- },
- uppercaseLabel: {
- textTransform: 'uppercase',
- },
-});
-
-export default FAB;
-
-// @component-docs ignore-next-line
-export { FAB };
diff --git a/src/components/FAB/FABGroup.tsx b/src/components/FAB/FABGroup.tsx
deleted file mode 100644
index 1123da16ae..0000000000
--- a/src/components/FAB/FABGroup.tsx
+++ /dev/null
@@ -1,533 +0,0 @@
-import * as React from 'react';
-import {
- Animated,
- GestureResponderEvent,
- Pressable,
- StyleProp,
- StyleSheet,
- TextStyle,
- View,
- ViewStyle,
-} from 'react-native';
-
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
-
-import FAB from './FAB';
-import { getFABGroupColors } from './utils';
-import { useInternalTheme } from '../../core/theming';
-import type { ThemeProp } from '../../types';
-import Card from '../Card/Card';
-import type { IconSource } from '../Icon';
-import Text from '../Typography/Text';
-
-const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
-
-export type Props = {
- /**
- * Action items to display in the form of a speed dial.
- * An action item should contain the following properties:
- * - `icon`: icon to display (required)
- * - `label`: optional label text
- * - `color`: custom icon color of the action item
- * - `labelTextColor`: custom label text color of the action item
- * - `accessibilityLabel`: accessibility label for the action, uses label by default if specified
- * - `accessibilityHint`: accessibility hint for the action
- * - `style`: pass additional styles for the fab item, for example, `backgroundColor`
- * - `containerStyle`: pass additional styles for the fab item label container, for example, `backgroundColor` @supported Available in 5.x
- * - `wrapperStyle`: pass additional styles for the wrapper of the action item.
- * - `labelStyle`: pass additional styles for the fab item label, for example, `fontSize`
- * - `labelMaxFontSizeMultiplier`: specifies the largest possible scale a title font can reach.
- * - `onPress`: callback that is called when `FAB` is pressed (required)
- * - `onLongPress`: callback that is called when `FAB` is long pressed
- * - `toggleStackOnLongPress`: callback that is called when `FAB` is long pressed
- * - `size`: size of action item. Defaults to `small`. @supported Available in v5.x
- * - `testID`: testID to be used on tests
- */
- actions: Array<{
- icon: IconSource;
- label?: string;
- color?: string;
- labelTextColor?: string;
- accessibilityLabel?: string;
- accessibilityHint?: string;
- style?: Animated.WithAnimatedValue>;
- containerStyle?: Animated.WithAnimatedValue>;
- wrapperStyle?: StyleProp;
- labelStyle?: StyleProp;
- labelMaxFontSizeMultiplier?: number;
- onPress: (e: GestureResponderEvent) => void;
- size?: 'small' | 'medium';
- testID?: string;
- }>;
- /**
- * Icon to display for the `FAB`.
- * You can toggle it based on whether the speed dial is open to display a different icon.
- */
- icon: IconSource;
- /**
- * Accessibility label for the FAB. This is read by the screen reader when the user taps the FAB.
- */
- accessibilityLabel?: string;
- /**
- * Custom color for the `FAB`.
- */
- color?: string;
- /**
- * Custom backdrop color for opened speed dial background.
- */
- backdropColor?: string;
- /**
- * Function to execute on pressing the `FAB`.
- */
- onPress?: (e: GestureResponderEvent) => void;
- /**
- * Function to execute on long pressing the `FAB`.
- */
- onLongPress?: (e: GestureResponderEvent) => void;
- /**
- * Makes actions stack appear on long press instead of on press.
- */
- toggleStackOnLongPress?: boolean;
- /**
- * Changes the delay for long press reaction.
- */
- delayLongPress?: number;
- /**
- * Allows for onLongPress when stack is opened.
- */
- enableLongPressWhenStackOpened?: boolean;
- /**
- * Whether the speed dial is open.
- */
- open: boolean;
- /**
- * Callback which is called on opening and closing the speed dial.
- * The open state needs to be updated when it's called, otherwise the change is dropped.
- */
- onStateChange: (state: { open: boolean }) => void;
- /**
- * Whether `FAB` is currently visible.
- */
- visible: boolean;
- /**
- * Style for the group. You can use it to pass additional styles if you need.
- * For example, you can set an additional padding if you have a tab bar at the bottom.
- */
- style?: StyleProp;
- /**
- * Style for the FAB. It allows to pass the FAB button styles, such as backgroundColor.
- */
- fabStyle?: Animated.WithAnimatedValue>;
- /**
- * @supported Available in v5.x with theme version 3
- *
- * Color mappings variant for combinations of container and icon colors.
- */
- variant?: 'primary' | 'secondary' | 'tertiary' | 'surface';
- /**
- * @optional
- */
- theme?: ThemeProp;
- /**
- * Optional label for `FAB`.
- */
- label?: string;
- /**
- * Pass down testID from Group props to FAB.
- */
- testID?: string;
-};
-
-/**
- * A component to display a stack of FABs with related actions in a speed dial.
- * To render the group above other components, you'll need to wrap it with the [`Portal`](../Portal) component.
- *
- * ## Usage
- * ```js
- * import * as React from 'react';
- * import { FAB, Portal, PaperProvider } from 'react-native-paper';
- *
- * const MyComponent = () => {
- * const [state, setState] = React.useState({ open: false });
- *
- * const onStateChange = ({ open }) => setState({ open });
- *
- * const { open } = state;
- *
- * return (
- *
- *
- * console.log('Pressed add') },
- * {
- * icon: 'star',
- * label: 'Star',
- * onPress: () => console.log('Pressed star'),
- * },
- * {
- * icon: 'email',
- * label: 'Email',
- * onPress: () => console.log('Pressed email'),
- * },
- * {
- * icon: 'bell',
- * label: 'Remind',
- * onPress: () => console.log('Pressed notifications'),
- * },
- * ]}
- * onStateChange={onStateChange}
- * onPress={() => {
- * if (open) {
- * // do something if the speed dial is open
- * }
- * }}
- * />
- *
- *
- * );
- * };
- *
- * export default MyComponent;
- * ```
- */
-const FABGroup = ({
- actions,
- icon,
- open,
- onPress,
- onLongPress,
- toggleStackOnLongPress,
- accessibilityLabel,
- theme: themeOverrides,
- style,
- fabStyle,
- visible,
- label,
- testID,
- onStateChange,
- color: colorProp,
- delayLongPress = 200,
- variant = 'primary',
- enableLongPressWhenStackOpened = false,
- backdropColor: customBackdropColor,
-}: Props) => {
- const theme = useInternalTheme(themeOverrides);
- const { top, bottom, right, left } = useSafeAreaInsets();
-
- const { current: backdrop } = React.useRef(
- new Animated.Value(0)
- );
- const animations = React.useRef(
- actions.map(() => new Animated.Value(open ? 1 : 0))
- );
-
- const [isClosingAnimationFinished, setIsClosingAnimationFinished] =
- React.useState(false);
-
- const [prevActions, setPrevActions] = React.useState<
- | {
- icon: IconSource;
- label?: string;
- color?: string;
- accessibilityLabel?: string;
- style?: Animated.WithAnimatedValue>;
- onPress: (e: GestureResponderEvent) => void;
- testID?: string;
- }[]
- | null
- >(null);
-
- const { scale } = theme.animation;
-
- React.useEffect(() => {
- if (open) {
- setIsClosingAnimationFinished(false);
- Animated.parallel([
- Animated.timing(backdrop, {
- toValue: 1,
- duration: 250 * scale,
- useNativeDriver: true,
- }),
- Animated.stagger(
- 15,
- animations.current
- .map((animation) =>
- Animated.timing(animation, {
- toValue: 1,
- duration: 150 * scale,
- useNativeDriver: true,
- })
- )
- .reverse()
- ),
- ]).start();
- } else {
- Animated.parallel([
- Animated.timing(backdrop, {
- toValue: 0,
- duration: 200 * scale,
- useNativeDriver: true,
- }),
- ...animations.current.map((animation) =>
- Animated.timing(animation, {
- toValue: 0,
- duration: 150 * scale,
- useNativeDriver: true,
- })
- ),
- ]).start(({ finished }) => {
- if (finished) {
- setIsClosingAnimationFinished(true);
- }
- });
- }
- }, [open, actions, backdrop, scale]);
-
- const close = () => onStateChange({ open: false });
- const toggle = () => onStateChange({ open: !open });
-
- const handlePress = (e: GestureResponderEvent) => {
- onPress?.(e);
- if (!toggleStackOnLongPress || open) {
- toggle();
- }
- };
-
- const handleLongPress = (e: GestureResponderEvent) => {
- if (!open || enableLongPressWhenStackOpened) {
- onLongPress?.(e);
- if (toggleStackOnLongPress) {
- toggle();
- }
- }
- };
-
- const {
- labelColor,
- backdropColor,
- backdropOpacity: backdropMaxOpacity,
- stackedFABBackgroundColor,
- } = getFABGroupColors({ theme, customBackdropColor });
-
- const backdropOpacity = open
- ? backdrop.interpolate({
- inputRange: [0, 0.5, 1],
- outputRange: [0, backdropMaxOpacity, backdropMaxOpacity],
- })
- : Animated.multiply(backdrop, backdropMaxOpacity);
-
- const opacities = animations.current;
-
- const translations = opacities.map((opacity) =>
- open
- ? opacity.interpolate({
- inputRange: [0, 1],
- outputRange: [24, -8],
- })
- : -8
- );
- const labelTranslations = opacities.map((opacity) =>
- open
- ? opacity.interpolate({
- inputRange: [0, 1],
- outputRange: [8, -8],
- })
- : -8
- );
-
- const containerPaddings = {
- paddingBottom: bottom,
- paddingRight: right,
- paddingLeft: left,
- paddingTop: top,
- };
-
- const actionsContainerVisibility: ViewStyle = {
- display: isClosingAnimationFinished ? 'none' : 'flex',
- };
-
- if (actions.length !== prevActions?.length) {
- animations.current = actions.map(
- (_, i) => animations.current[i] || new Animated.Value(open ? 1 : 0)
- );
- setPrevActions(actions);
- }
-
- return (
-
-
-
-
- {actions.map((it, i) => {
- const labelTextStyle = {
- color: it.labelTextColor ?? labelColor,
- ...theme.fonts.titleMedium,
- };
- const marginHorizontal =
- typeof it.size === 'undefined' || it.size === 'small' ? 24 : 16;
- const accessibilityLabel =
- typeof it.accessibilityLabel !== 'undefined'
- ? it.accessibilityLabel
- : it.label;
- const size = typeof it.size !== 'undefined' ? it.size : 'small';
-
- const handleActionPress = (e: GestureResponderEvent) => {
- it.onPress(e);
- close();
- };
-
- return (
-
- {it.label && (
-
-
-
- {it.label}
-
-
-
- )}
-
-
- );
- })}
-
-
-
-
- );
-};
-
-FABGroup.displayName = 'FAB.Group';
-
-export default FABGroup;
-
-// @component-docs ignore-next-line
-export { FABGroup };
-
-const styles = StyleSheet.create({
- safeArea: {
- alignItems: 'flex-end',
- },
- container: {
- ...StyleSheet.absoluteFill,
- justifyContent: 'flex-end',
- },
- fab: {
- marginHorizontal: 16,
- marginBottom: 16,
- marginTop: 0,
- },
- backdrop: {
- ...StyleSheet.absoluteFill,
- },
- containerStyle: {
- borderRadius: 5,
- paddingHorizontal: 12,
- paddingVertical: 6,
- marginVertical: 8,
- marginHorizontal: 16,
- elevation: 2,
- },
- item: {
- marginBottom: 16,
- flexDirection: 'row',
- justifyContent: 'flex-end',
- alignItems: 'center',
- },
- // eslint-disable-next-line react-native/no-color-literals
- v3ContainerStyle: {
- backgroundColor: 'transparent',
- elevation: 0,
- },
-});
diff --git a/src/components/FAB/FabContent.tsx b/src/components/FAB/FabContent.tsx
new file mode 100644
index 0000000000..06170f1709
--- /dev/null
+++ b/src/components/FAB/FabContent.tsx
@@ -0,0 +1,160 @@
+import * as React from 'react';
+import {
+ ColorValue,
+ ScrollView,
+ StyleProp,
+ StyleSheet,
+ Text as NativeText,
+ TextLayoutEvent,
+ View,
+ ViewStyle,
+} from 'react-native';
+
+import Reanimated, { AnimatedStyle } from 'react-native-reanimated';
+
+import type { TypescaleKey } from '../../theme/types';
+import Icon, { IconSource } from '../Icon';
+import AnimatedText from '../Typography/AnimatedText';
+
+export type FabContentProps = {
+ icon?: IconSource;
+ label?: string;
+ contentColor: ColorValue;
+ height: number;
+ iconSize: number;
+ leading: number;
+ trailing: number;
+ iconLabelGap: number;
+ labelTypescale?: TypescaleKey;
+ labelMaxFontSizeMultiplier?: number;
+ /**
+ * Reanimated style merged onto the label wrapper. Used by the Extended FAB
+ * to fade the label in and out as the FAB expands and collapses.
+ */
+ labelAnimatedStyle?: StyleProp>;
+ /**
+ * Ref to the visible label node. Used by the Extended FAB to measure label
+ * width on the web.
+ */
+ labelRef?: React.RefObject<(NativeText & HTMLElement) | null>;
+ /**
+ * `onTextLayout` for the visible label. Used by iOS, which reports the full
+ * (unclipped) label width via this callback. Pass `undefined` on platforms
+ * where the visible label is clipped and reports a useless width.
+ */
+ onLabelTextLayout?: (e: TextLayoutEvent) => void;
+ labelNumberOfLines?: number;
+ labelEllipsisMode?: 'clip' | 'tail' | 'head' | 'middle';
+ /**
+ * When set, an off-screen copy of the label is rendered with this callback
+ * attached. Used by the Extended FAB on Android, where the visible label's
+ * `onTextLayout` reports only the visible glyph run.
+ */
+ offscreenLabelMeasure?: (e: TextLayoutEvent) => void;
+ testID?: string;
+};
+
+/**
+ * Internal layout primitive: an icon-and-label row used by every FAB-flavored
+ * surface in this package (regular, Extended, Menu trigger, Menu item).
+ *
+ * No animation, no ripple, no shadow, no container shape. Just the content.
+ */
+const FabContent = ({
+ icon,
+ label,
+ contentColor,
+ height,
+ iconSize,
+ leading,
+ trailing,
+ iconLabelGap,
+ labelTypescale = 'labelLarge',
+ labelMaxFontSizeMultiplier,
+ labelAnimatedStyle,
+ labelRef,
+ onLabelTextLayout,
+ labelNumberOfLines,
+ labelEllipsisMode,
+ offscreenLabelMeasure,
+ testID,
+}: FabContentProps) => {
+ const hasLabel = label !== undefined && label !== '';
+ const colorStyle = { color: contentColor };
+
+ return (
+ <>
+
+ {icon ? (
+
+ ) : null}
+ {hasLabel ? (
+
+
+ {label}
+
+
+ ) : null}
+
+ {hasLabel && offscreenLabelMeasure ? (
+
+
+ {label}
+
+
+ ) : null}
+ >
+ );
+};
+
+const styles = StyleSheet.create({
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ pointerEvents: 'none',
+ },
+ rowIconOnly: {
+ justifyContent: 'center',
+ },
+ labelNoPointerEvents: {
+ pointerEvents: 'none',
+ },
+ offscreen: {
+ height: 0,
+ position: 'absolute',
+ },
+});
+
+export default FabContent;
diff --git a/src/components/FAB/FabShell.tsx b/src/components/FAB/FabShell.tsx
new file mode 100644
index 0000000000..9644666619
--- /dev/null
+++ b/src/components/FAB/FabShell.tsx
@@ -0,0 +1,396 @@
+import * as React from 'react';
+import {
+ AccessibilityState,
+ ColorValue,
+ GestureResponderEvent,
+ Platform,
+ PressableAndroidRippleConfig,
+ StyleProp,
+ StyleSheet,
+ Text as NativeText,
+ TextLayoutEvent,
+ View,
+ ViewStyle,
+} from 'react-native';
+
+import Reanimated, {
+ AnimatedStyle,
+ useAnimatedStyle,
+ useSharedValue,
+ type SharedValue,
+} from 'react-native-reanimated';
+
+import FabContent from './FabContent';
+import {
+ FloatingActionButtonSize,
+ FloatingActionButtonTokens,
+ FloatingActionButtonVariant,
+ FOCUS_RING_INSET,
+ FOCUS_RING_THICKNESS,
+ webNoOutline,
+} from './tokens';
+import { useFabVisibility } from './useFabVisibility';
+import { useFocusRing } from './useFocusRing';
+import { getDimensions, resolveColors } from './utils';
+import { useInternalTheme } from '../../core/theming';
+import type { ShapeToken } from '../../theme/utils/shape';
+import type { Elevation, ThemeProp } from '../../types';
+import { forwardRef } from '../../utils/forwardRef';
+import type { IconSource } from '../Icon';
+import TouchableRipple from '../TouchableRipple/TouchableRipple';
+
+export type FabShellProps = {
+ /**
+ * Icon rendered inside the FAB when no custom `children` are provided.
+ */
+ icon?: IconSource;
+ /**
+ * Label rendered next to the icon when no custom `children` are provided.
+ * When present, the FAB grows to fit.
+ */
+ label?: string;
+ /**
+ * Role-color preset. Defaults to `tonalPrimary`.
+ */
+ variant?: FloatingActionButtonVariant;
+ /**
+ * Spec size. Defaults to `default`.
+ */
+ size?: FloatingActionButtonSize;
+ /**
+ * Container color override. Wins over `variant`.
+ */
+ containerColor?: ColorValue;
+ /**
+ * Content color override. Wins over `variant`.
+ */
+ contentColor?: ColorValue;
+ /**
+ * Shape override. Defaults to the size-driven shape token.
+ */
+ shape?: ShapeToken;
+ /**
+ * Icon size override.
+ */
+ iconSize?: number;
+ /**
+ * Leading-padding override.
+ */
+ leading?: number;
+ /**
+ * Trailing-padding override.
+ */
+ trailing?: number;
+ /**
+ * Resting elevation level. Defaults to the FAB's enabled-state elevation.
+ * Pass `0` to disable the shadow entirely.
+ */
+ elevation?: Elevation;
+ /**
+ * When `false`, the shell animates out (scale + alpha) and stops accepting
+ * touches.
+ */
+ visible?: boolean;
+ /**
+ * Function to execute on press.
+ */
+ onPress?: (e: GestureResponderEvent) => void;
+ /**
+ * Accessibility label. Falls back to `label` if unset.
+ */
+ accessibilityLabel?: string;
+ /**
+ * Accessibility state forwarded to the underlying button.
+ */
+ accessibilityState?: AccessibilityState;
+ /**
+ * Largest scale the label font can reach (auto-built content only).
+ */
+ labelMaxFontSizeMultiplier?: number;
+ /**
+ * Animated style merged onto the label wrapper. Used by the Extended FAB
+ * to fade the label in and out as the FAB expands and collapses.
+ */
+ labelAnimatedStyle?: StyleProp>;
+ /**
+ * Ref to the visible label node. Used by the Extended FAB to measure
+ * label width on the web.
+ */
+ labelRef?: React.RefObject<(NativeText & HTMLElement) | null>;
+ /**
+ * `onTextLayout` for the visible label. Used on iOS, which reports the
+ * full (unclipped) label width via this callback.
+ */
+ onLabelTextLayout?: (e: TextLayoutEvent) => void;
+ /**
+ * `onTextLayout` for an off-screen full-width copy of the label. Used on
+ * Android, where the visible label's `onTextLayout` reports only the
+ * visible glyph run.
+ */
+ offscreenLabelMeasure?: (e: TextLayoutEvent) => void;
+ /**
+ * Type of background drawable to display the feedback (Android).
+ */
+ background?: PressableAndroidRippleConfig;
+ /**
+ * Shared value driving the outer's animated width. When omitted, the
+ * outer is sized by its content (icon FAB) or the size token
+ * (`dimensions.width`).
+ */
+ widthShared?: SharedValue;
+ /**
+ * Shared value driving the outer's animated height. When omitted, the
+ * outer is sized by its content.
+ */
+ heightShared?: SharedValue;
+ /**
+ * Shared value driving the outer's animated borderRadius. The same value
+ * is applied to the inner clip so children are clipped to the same shape.
+ * When omitted, the static size-driven radius is used.
+ */
+ borderRadiusShared?: SharedValue;
+ /**
+ * When `true`, both outer and clip render with `backgroundColor: transparent`
+ * so the consumer can paint the surface via the `overlay` slot (used by the
+ * morph trigger's cross-faded color planes).
+ */
+ transparentBackground?: boolean;
+ /**
+ * Absolutely-positioned content rendered inside the shell, behind the icon
+ * and label row. Used by the morphing trigger to cross-fade color planes.
+ */
+ overlay?: React.ReactNode;
+ /**
+ * Replaces the default icon + label content. Pass your own ``
+ * when you need custom typescale, label animation, or measurement.
+ */
+ children?: React.ReactNode;
+ /**
+ * Outer-positioning style. Visual treatment (size, shape, color) comes from
+ * `variant` and `size`.
+ */
+ style?: StyleProp;
+ /**
+ * TestID used for testing purposes.
+ */
+ testID?: string;
+ /**
+ * @optional
+ */
+ theme?: ThemeProp;
+ ref?: React.RefObject;
+};
+
+/**
+ * Internal shell used by every FAB-flavored component (regular, Extended,
+ * morphing menu trigger). Owns the outer container, ripple, clip, and the
+ * visibility animation (scale + alpha + shadow). Consumers that need to
+ * animate the outer's width/height/borderRadius pass shared values; the
+ * static size-driven defaults are used otherwise.
+ *
+ * Not exported from the package.
+ */
+const FabShell = forwardRef(
+ (
+ {
+ icon,
+ label,
+ variant = 'tonalPrimary',
+ size = 'default',
+ containerColor,
+ contentColor,
+ shape,
+ iconSize,
+ leading,
+ trailing,
+ elevation = FloatingActionButtonTokens.stateElevation.enabled,
+ visible = true,
+ onPress,
+ accessibilityLabel = label,
+ accessibilityState,
+ labelMaxFontSizeMultiplier,
+ labelAnimatedStyle,
+ labelRef,
+ onLabelTextLayout,
+ offscreenLabelMeasure,
+ background,
+ widthShared,
+ heightShared,
+ borderRadiusShared,
+ transparentBackground = false,
+ overlay,
+ children,
+ style,
+ testID = 'fab-shell',
+ theme: themeOverrides,
+ },
+ ref
+ ) => {
+ const theme = useInternalTheme(themeOverrides);
+
+ const dimensions = React.useMemo(
+ () => getDimensions({ theme, size, shape, iconSize, leading, trailing }),
+ [theme, size, shape, iconSize, leading, trailing]
+ );
+
+ const colors = React.useMemo(
+ () => resolveColors({ theme, variant, containerColor, contentColor }),
+ [theme, variant, containerColor, contentColor]
+ );
+
+ const { scale, alpha, shadowStyle } = useFabVisibility({
+ visible,
+ theme,
+ elevation,
+ });
+
+ // Fallback shared values track the static size-driven dimensions. Consumers
+ // that don't supply their own animated shared values get these. Keeping
+ // everything as a shared value means there's exactly one animated style
+ // per view — no static-vs-animated merge surprises.
+ const fallbackWidth = useSharedValue(dimensions.width);
+ const fallbackHeight = useSharedValue(dimensions.height);
+ const fallbackBorderRadius = useSharedValue(dimensions.borderRadius);
+ React.useEffect(() => {
+ fallbackWidth.value = dimensions.width;
+ fallbackHeight.value = dimensions.height;
+ fallbackBorderRadius.value = dimensions.borderRadius;
+ }, [
+ dimensions.width,
+ dimensions.height,
+ dimensions.borderRadius,
+ fallbackWidth,
+ fallbackHeight,
+ fallbackBorderRadius,
+ ]);
+
+ const width = widthShared ?? fallbackWidth;
+ const height = heightShared ?? fallbackHeight;
+ const borderRadius = borderRadiusShared ?? fallbackBorderRadius;
+ const containerBg = transparentBackground
+ ? 'transparent'
+ : colors.container;
+
+ const outerStyle = useAnimatedStyle(
+ () => ({
+ transform: [{ scale: scale.value }],
+ opacity: alpha.value,
+ width: width.value,
+ height: height.value,
+ borderRadius: borderRadius.value,
+ backgroundColor: containerBg,
+ }),
+ [width, height, borderRadius, containerBg]
+ );
+
+ const clipStyle = useAnimatedStyle(
+ () => ({
+ borderRadius: borderRadius.value,
+ backgroundColor: containerBg,
+ }),
+ [borderRadius, containerBg]
+ );
+
+ const { focusedSV, onFocus, onBlur } = useFocusRing();
+ const focusRingStyle = useAnimatedStyle(
+ () => ({
+ opacity: focusedSV.value ? 1 : 0,
+ borderRadius: borderRadius.value + FOCUS_RING_INSET,
+ }),
+ [borderRadius]
+ );
+
+ return (
+
+
+ {overlay}
+
+ {children ?? (
+
+ )}
+
+
+
+
+ );
+ }
+);
+
+const styles = StyleSheet.create({
+ container: {
+ transformOrigin: 'center',
+ },
+ clip: {
+ width: '100%',
+ height: '100%',
+ overflow: 'hidden',
+ },
+ fill: {
+ flex: 1,
+ },
+ pointerEventsAuto: {
+ pointerEvents: 'auto',
+ },
+ pointerEventsNone: {
+ pointerEvents: 'none',
+ },
+ focusRing: {
+ position: 'absolute',
+ top: -FOCUS_RING_INSET,
+ left: -FOCUS_RING_INSET,
+ right: -FOCUS_RING_INSET,
+ bottom: -FOCUS_RING_INSET,
+ borderWidth: FOCUS_RING_THICKNESS,
+ pointerEvents: 'none',
+ },
+});
+
+export default FabShell;
diff --git a/src/components/FAB/FloatingActionButton.tsx b/src/components/FAB/FloatingActionButton.tsx
new file mode 100644
index 0000000000..2e48fefc71
--- /dev/null
+++ b/src/components/FAB/FloatingActionButton.tsx
@@ -0,0 +1,137 @@
+import * as React from 'react';
+import {
+ AccessibilityState,
+ GestureResponderEvent,
+ PressableAndroidRippleConfig,
+ StyleProp,
+ View,
+ ViewStyle,
+} from 'react-native';
+
+import FabShell from './FabShell';
+import {
+ FloatingActionButtonSize,
+ FloatingActionButtonVariant,
+} from './tokens';
+import type { ThemeProp } from '../../types';
+import { forwardRef } from '../../utils/forwardRef';
+import type { IconSource } from '../Icon';
+
+export type Props = {
+ /**
+ * Icon to display inside the FAB.
+ */
+ icon: IconSource;
+ /**
+ * Role-color preset. Defaults to `tonalPrimary`.
+ */
+ variant?: FloatingActionButtonVariant;
+ /**
+ * Spec size. Defaults to `default`.
+ */
+ size?: FloatingActionButtonSize;
+ /**
+ * Whether the FAB is currently visible. Toggling animates the spec'd enter
+ * and exit (scale + alpha) on the FAB itself.
+ */
+ visible?: boolean;
+ /**
+ * Function to execute on press.
+ */
+ onPress?: (e: GestureResponderEvent) => void;
+ /**
+ * Accessibility label. Falls back to nothing if unset.
+ */
+ accessibilityLabel?: string;
+ /**
+ * Accessibility state forwarded to the underlying button.
+ */
+ accessibilityState?: AccessibilityState;
+ /**
+ * Type of background drawable to display the feedback (Android).
+ * https://reactnative.dev/docs/pressable#rippleconfig
+ */
+ background?: PressableAndroidRippleConfig;
+ /**
+ * Style for positioning the FAB. The visual treatment (size, shape, color)
+ * is driven by `variant` and `size`.
+ */
+ style?: StyleProp;
+ /**
+ * TestID used for testing purposes.
+ */
+ testID?: string;
+ /**
+ * @optional
+ */
+ theme?: ThemeProp;
+ ref?: React.RefObject;
+};
+
+/**
+ * A floating action button represents the primary action on a screen.
+ *
+ * ## Usage
+ * ```js
+ * import * as React from 'react';
+ * import { StyleSheet } from 'react-native';
+ * import { FloatingActionButton } from 'react-native-paper';
+ *
+ * const MyComponent = () => (
+ * console.log('Pressed')}
+ * />
+ * );
+ *
+ * const styles = StyleSheet.create({
+ * fab: {
+ * position: 'absolute',
+ * margin: 16,
+ * right: 0,
+ * bottom: 0,
+ * },
+ * });
+ *
+ * export default MyComponent;
+ * ```
+ */
+const FloatingActionButton = forwardRef(
+ (
+ {
+ icon,
+ variant = 'tonalPrimary',
+ size = 'default',
+ visible = true,
+ onPress,
+ accessibilityLabel,
+ accessibilityState,
+ background,
+ style,
+ testID = 'floating-action-button',
+ theme,
+ },
+ ref
+ ) => (
+
+ )
+);
+
+export default FloatingActionButton;
+
+// @component-docs ignore-next-line
+export { FloatingActionButton };
diff --git a/src/components/FAB/FloatingActionButtonMenu.tsx b/src/components/FAB/FloatingActionButtonMenu.tsx
new file mode 100644
index 0000000000..4d46557bea
--- /dev/null
+++ b/src/components/FAB/FloatingActionButtonMenu.tsx
@@ -0,0 +1,767 @@
+import * as React from 'react';
+import {
+ ColorValue,
+ GestureResponderEvent,
+ Platform,
+ StyleSheet,
+ View,
+} from 'react-native';
+
+import Animated, {
+ interpolate,
+ useAnimatedStyle,
+ useDerivedValue,
+ useSharedValue,
+ withDelay,
+ withSpring,
+} from 'react-native-reanimated';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import FabContent from './FabContent';
+import FabShell from './FabShell';
+import {
+ FloatingActionButtonMenuTokens,
+ FloatingActionButtonSize,
+ FloatingActionButtonTokens,
+ FloatingActionButtonVariant,
+ FOCUS_RING_INSET,
+ FOCUS_RING_THICKNESS,
+ webNoOutline,
+} from './tokens';
+import { useFocusRing } from './useFocusRing';
+import { resolveColors } from './utils';
+import { useLocale } from '../../core/locale';
+import { useInternalTheme } from '../../core/theming';
+import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext';
+import { toRawSpring } from '../../theme/tokens/sys/motion';
+import { resolveCornerRadius } from '../../theme/utils/shape';
+import type { InternalTheme, ThemeProp } from '../../types';
+import Icon, { IconSource } from '../Icon';
+import TouchableRipple from '../TouchableRipple/TouchableRipple';
+
+export type FloatingActionButtonMenuItemProps = {
+ /**
+ * Optional icon for the item.
+ */
+ icon?: IconSource;
+ /**
+ * Mandatory label.
+ */
+ label: string;
+ /**
+ * Called when the item is pressed. The menu is dismissed automatically
+ * after `onPress` runs.
+ */
+ onPress: (e: GestureResponderEvent) => void;
+ /**
+ * Accessibility label. Falls back to `label`.
+ */
+ accessibilityLabel?: string;
+ testID?: string;
+};
+
+const FloatingActionButtonMenuItem = (
+ _props: FloatingActionButtonMenuItemProps
+): React.ReactElement | null => null;
+FloatingActionButtonMenuItem.displayName = 'FloatingActionButtonMenu.Item';
+
+export type FloatingActionButtonMenuProps = {
+ /**
+ * Whether the menu is open.
+ */
+ expanded: boolean;
+ /**
+ * Called when the user taps the close button or taps an item.
+ */
+ onDismiss?: () => void;
+ /**
+ * Trigger FAB. Pass a ``. The menu reads its
+ * `variant`, `size`, `icon`, and `onPress` and renders a single morphing
+ * FAB that animates between the trigger and the spec'd close button.
+ */
+ button: React.ReactElement;
+ /**
+ * Horizontal side the menu sits on. Default `'end'`.
+ */
+ horizontalAlignment?: 'start' | 'center' | 'end';
+ /**
+ * Icon used by the close button when the menu is expanded. Default
+ * `'close'`.
+ */
+ closeIcon?: IconSource;
+ /**
+ * Menu items as ``. Spec calls for 2 to 6
+ * items; a dev-mode warning fires outside that range.
+ */
+ children: React.ReactNode;
+ testID?: string;
+ /**
+ * @optional
+ */
+ theme?: ThemeProp;
+};
+
+/**
+ * Per the M3 FAB Menu spec, the menu picks one of three color sets (primary,
+ * secondary, tertiary) based on which family the trigger FAB belongs to.
+ * The close button is always the saturated role color; items are always the
+ * tonal (container) role color.
+ */
+const getCloseVariant = (
+ triggerVariant: FloatingActionButtonVariant
+): FloatingActionButtonVariant => {
+ if (triggerVariant === 'primary' || triggerVariant === 'tonalPrimary') {
+ return 'primary';
+ }
+ if (triggerVariant === 'secondary' || triggerVariant === 'tonalSecondary') {
+ return 'secondary';
+ }
+ return 'tertiary';
+};
+
+const getItemsVariant = (
+ triggerVariant: FloatingActionButtonVariant
+): FloatingActionButtonVariant => {
+ if (triggerVariant === 'primary' || triggerVariant === 'tonalPrimary') {
+ return 'tonalPrimary';
+ }
+ if (triggerVariant === 'secondary' || triggerVariant === 'tonalSecondary') {
+ return 'tonalSecondary';
+ }
+ return 'tonalTertiary';
+};
+
+type ButtonExtractableProps = {
+ variant?: FloatingActionButtonVariant;
+ size?: FloatingActionButtonSize;
+ icon?: IconSource;
+ containerColor?: ColorValue;
+ contentColor?: ColorValue;
+ visible?: boolean;
+ onPress?: (e: GestureResponderEvent) => void;
+ accessibilityLabel?: string;
+ testID?: string;
+};
+
+// Per-item delay used by the stagger. Compose uses a single SlowEffects-driven
+// integer count that crosses each item's threshold; we approximate with a
+// fixed delay per index.
+const STAGGER_MS = 30;
+
+type AnimatedItemProps = {
+ expanded: boolean;
+ index: number;
+ itemCount: number;
+ theme: InternalTheme;
+ transformOrigin: 'left' | 'center' | 'right';
+ marginBottom: number;
+ children: React.ReactNode;
+};
+
+const AnimatedItem = ({
+ expanded,
+ index,
+ itemCount,
+ theme,
+ transformOrigin,
+ marginBottom,
+ children,
+}: AnimatedItemProps) => {
+ const reduceMotion = useReduceMotion();
+ // Initial values match the resting state for the current `expanded` prop so
+ // first mount doesn't animate unexpectedly.
+ const scaleX = useSharedValue(expanded ? 1 : 0);
+ const alpha = useSharedValue(expanded ? 1 : 0);
+
+ React.useEffect(() => {
+ const target = expanded ? 1 : 0;
+ // Bottom-up on open, top-down on close (matches Compose).
+ const delay = expanded
+ ? (itemCount - 1 - index) * STAGGER_MS
+ : index * STAGGER_MS;
+
+ if (reduceMotion) {
+ scaleX.value = target;
+ alpha.value = target;
+ return;
+ }
+ scaleX.value = withDelay(
+ delay,
+ withSpring(target, toRawSpring(theme.motion.spring.fast.spatial))
+ );
+ alpha.value = withDelay(
+ delay,
+ withSpring(target, toRawSpring(theme.motion.spring.fast.effects))
+ );
+ }, [expanded, index, itemCount, theme, reduceMotion, scaleX, alpha]);
+
+ // Only scaleX and opacity animate. Layout height stays at the item's
+ // natural size — the items container is absolutely positioned above the
+ // trigger, so this fixed height never affects the trigger's position.
+ const animStyle = useAnimatedStyle(() => ({
+ transform: [{ scaleX: scaleX.value }],
+ opacity: alpha.value,
+ }));
+
+ return (
+
+ {children}
+
+ );
+};
+
+type MenuItemProps = {
+ icon?: IconSource;
+ label: string;
+ variant: FloatingActionButtonVariant;
+ theme: InternalTheme;
+ onPress: (e: GestureResponderEvent) => void;
+ accessibilityLabel?: string;
+ testID?: string;
+};
+
+/**
+ * A single FAB Menu item. Visually a tonal pill with an icon and a label,
+ * but it is not a floating action button: no shadow, no enter/exit scaling
+ * of its own (the surrounding `AnimatedItem` handles entrance), and its
+ * shape and dimensions come from the menu spec rather than FAB tokens.
+ */
+const MenuItem = ({
+ icon,
+ label,
+ variant,
+ theme,
+ onPress,
+ accessibilityLabel,
+ testID,
+}: MenuItemProps) => {
+ const colors = resolveColors({ theme, variant });
+ const { height, iconSize, leading, trailing, iconLabelGap, shape } =
+ FloatingActionButtonMenuTokens.listItem;
+ const borderRadius = resolveCornerRadius(theme, shape);
+
+ const { focusedSV, onFocus, onBlur } = useFocusRing();
+ const focusRingStyle = useAnimatedStyle(() => ({
+ opacity: focusedSV.value ? 1 : 0,
+ }));
+
+ return (
+
+
+
+
+
+
+
+
+ );
+};
+
+type MorphingTriggerProps = {
+ triggerVariant: FloatingActionButtonVariant;
+ closeVariant: FloatingActionButtonVariant;
+ triggerContainerColor?: ColorValue;
+ triggerContentColor?: ColorValue;
+ size: FloatingActionButtonSize;
+ openIcon: IconSource;
+ closeIcon: IconSource;
+ expanded: boolean;
+ /** Whether the trigger FAB is visible; drives the scale/alpha enter/exit. */
+ visible: boolean;
+ horizontalAlignment: 'start' | 'center' | 'end';
+ onPress?: (e: GestureResponderEvent) => void;
+ accessibilityLabel?: string;
+ theme: InternalTheme;
+ testID?: string;
+};
+
+const MorphingTrigger = ({
+ triggerVariant,
+ closeVariant,
+ triggerContainerColor,
+ triggerContentColor,
+ size,
+ openIcon,
+ closeIcon,
+ expanded,
+ visible,
+ horizontalAlignment,
+ onPress,
+ accessibilityLabel,
+ theme,
+ testID,
+}: MorphingTriggerProps) => {
+ const reduceMotion = useReduceMotion();
+
+ const closedSpec = FloatingActionButtonTokens.sizes[size];
+ const closedContainer = closedSpec.container;
+ const closedIconSize = closedSpec.icon;
+ const closedBorderRadius = resolveCornerRadius(theme, closedSpec.shape);
+
+ const openContainer = FloatingActionButtonMenuTokens.closeButton.container;
+ const openIconSize = FloatingActionButtonMenuTokens.closeButton.iconSize;
+ // Use container/2 (instead of the cornerFull sentinel) as the open radius,
+ // so the interpolation produces a smooth round-corner morph rather than
+ // jumping past the visual "circle" threshold almost immediately.
+ const openBorderRadius = openContainer / 2;
+
+ // Trigger color set (respects user overrides) and close color set (always
+ // the saturated role color per spec — no overrides).
+ const triggerColors = resolveColors({
+ theme,
+ variant: triggerVariant,
+ containerColor: triggerContainerColor,
+ contentColor: triggerContentColor,
+ });
+ const closeColors = resolveColors({ theme, variant: closeVariant });
+
+ const progress = useSharedValue(expanded ? 1 : 0);
+
+ React.useEffect(() => {
+ if (reduceMotion) {
+ progress.value = expanded ? 1 : 0;
+ return;
+ }
+ // Compose's ToggleFloatingActionButton uses a single FastSpatial spring
+ // for the full open/close progress (size, corner, color, icon all share
+ // one timeline).
+ progress.value = withSpring(
+ expanded ? 1 : 0,
+ toRawSpring(theme.motion.spring.fast.spatial)
+ );
+ }, [expanded, theme, reduceMotion, progress]);
+
+ // Derived shared values for the morph shape. Passing them to FabShell as
+ // individual shared values (rather than packing them into an animated
+ // style) means FabShell can put each into a single `useAnimatedStyle` with
+ // no inter-style merge surprises. Explicit deps so toggling `size` while
+ // the menu is open re-derives immediately — e.g. closed-state values
+ // change to match the new size's resting shape, and the close-state values
+ // (always 56 / 28) keep the open shape circular.
+ const widthShared = useDerivedValue(
+ () => interpolate(progress.value, [0, 1], [closedContainer, openContainer]),
+ [closedContainer, openContainer]
+ );
+ const heightShared = useDerivedValue(
+ () => interpolate(progress.value, [0, 1], [closedContainer, openContainer]),
+ [closedContainer, openContainer]
+ );
+ const borderRadiusShared = useDerivedValue(
+ () =>
+ interpolate(
+ progress.value,
+ [0, 1],
+ [closedBorderRadius, openBorderRadius]
+ ),
+ [closedBorderRadius, openBorderRadius]
+ );
+
+ const openPlaneStyle = useAnimatedStyle(() => ({
+ opacity: 1 - progress.value,
+ }));
+ const closePlaneStyle = useAnimatedStyle(() => ({
+ opacity: progress.value,
+ }));
+
+ // Outer slot is fixed at the trigger's resting size; the FAB itself
+ // shrinks toward the top-{start|center|end} corner of that slot when
+ // expanded (only meaningful for medium / large sizes).
+ const slotAlign: 'flex-start' | 'center' | 'flex-end' =
+ horizontalAlignment === 'start'
+ ? 'flex-start'
+ : horizontalAlignment === 'center'
+ ? 'center'
+ : 'flex-end';
+
+ return (
+
+
+
+
+ >
+ }
+ theme={theme}
+ >
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+/**
+ * Floating action button menu. Wraps a trigger FAB; when `expanded` is true,
+ * items appear stacked above and the trigger morphs into the spec'd close
+ * button (`shape: 'full'`, 56 dp, saturated role color).
+ *
+ * No visual backdrop and no outside-tap dismiss — that matches the MD3 spec
+ * and lets the user keep interacting with the content underneath. Dismiss
+ * via the close button or by tapping an item.
+ *
+ * ## Usage
+ * ```tsx
+ * const [open, setOpen] = React.useState(false);
+ *
+ *
+ * setOpen(false)}
+ * button={
+ * setOpen(true)}
+ * />
+ * }
+ * >
+ * {}}
+ * />
+ * {}}
+ * />
+ *
+ *
+ * ```
+ */
+const FloatingActionButtonMenu = ({
+ expanded,
+ onDismiss,
+ button,
+ horizontalAlignment = 'end',
+ closeIcon = 'close',
+ children,
+ testID = 'floating-action-button-menu',
+ theme: themeOverrides,
+}: FloatingActionButtonMenuProps) => {
+ const theme = useInternalTheme(themeOverrides);
+ const { direction } = useLocale();
+ const isRTL = direction === 'rtl';
+ const insets = useSafeAreaInsets();
+
+ const items = React.Children.toArray(children)
+ .filter(
+ (child): child is React.ReactElement =>
+ React.isValidElement(child) &&
+ child.type === FloatingActionButtonMenuItem
+ )
+ .map((child) => child.props);
+
+ if (
+ process.env.NODE_ENV !== 'production' &&
+ (items.length < 2 || items.length > 6)
+ ) {
+ console.warn(
+ `FloatingActionButtonMenu expects 2 to 6 items; received ${items.length}.`
+ );
+ }
+
+ const buttonProps: ButtonExtractableProps = React.isValidElement(button)
+ ? (button.props as ButtonExtractableProps)
+ : {};
+ const triggerVariant: FloatingActionButtonVariant =
+ buttonProps.variant ?? 'tonalPrimary';
+ const size: FloatingActionButtonSize = buttonProps.size ?? 'default';
+ const openIcon: IconSource = buttonProps.icon ?? 'plus';
+ const openOnPress = buttonProps.onPress;
+ const triggerVisible = buttonProps.visible ?? true;
+ const closeVariant = getCloseVariant(triggerVariant);
+ const itemsVariant = getItemsVariant(triggerVariant);
+
+ // When the trigger isn't visible, items don't either; they share the
+ // FAB's enter/exit.
+ const effectiveExpanded = triggerVisible && expanded;
+
+ const handleItemPress =
+ (item: FloatingActionButtonMenuItemProps) => (e: GestureResponderEvent) => {
+ item.onPress(e);
+ onDismiss?.();
+ };
+
+ const alignment: 'flex-start' | 'center' | 'flex-end' =
+ horizontalAlignment === 'start'
+ ? 'flex-start'
+ : horizontalAlignment === 'center'
+ ? 'center'
+ : 'flex-end';
+ // Per-item motion is purely horizontal (matches Compose's width animation);
+ // the bottom-up feel comes from the stagger order, not the scale anchor.
+ // RN auto-mirrors `left`/`right` position styles in RTL, so the items
+ // container visually moves to the opposite edge — but `transformOrigin` is
+ // a transform property and is NOT auto-flipped. Invert the mapping in RTL
+ // so each item still scales out from the screen-edge side rather than the
+ // screen-center side.
+ const itemTransformOrigin: 'left' | 'center' | 'right' =
+ horizontalAlignment === 'start'
+ ? isRTL
+ ? 'right'
+ : 'left'
+ : horizontalAlignment === 'center'
+ ? 'center'
+ : isRTL
+ ? 'left'
+ : 'right';
+ // The trigger's slot is fixed at the original FAB's size. The close button
+ // (always 56 dp) anchors to the top of this slot when expanded.
+ const triggerSlotSize = FloatingActionButtonTokens.sizes[size].container;
+
+ return (
+
+
+ {/* Absolutely positioned above the trigger so item layout (and the
+ scaleX bounce on each item) never affects the trigger's position
+ — no vertical spring. The items container sits closeToLastItem
+ above the original FAB slot. */}
+
+ {items.map((item, index) => {
+ const isLast = index === items.length - 1;
+ return (
+
+
+
+ );
+ })}
+
+
+
+
+ );
+};
+
+FloatingActionButtonMenu.Item = FloatingActionButtonMenuItem;
+FloatingActionButtonMenu.displayName = 'FloatingActionButtonMenu';
+
+const styles = StyleSheet.create({
+ container: {
+ ...StyleSheet.absoluteFill,
+ justifyContent: 'flex-end',
+ pointerEvents: 'box-none',
+ },
+ stack: {
+ flexDirection: 'column',
+ pointerEvents: 'box-none',
+ },
+ items: {
+ position: 'absolute',
+ flexDirection: 'column',
+ pointerEvents: 'box-none',
+ },
+ itemsStart: {
+ left: 0,
+ },
+ itemsCenter: {
+ left: 0,
+ right: 0,
+ },
+ itemsEnd: {
+ right: 0,
+ },
+ menuItemWrapper: {
+ position: 'relative',
+ },
+ menuItem: {
+ overflow: 'hidden',
+ },
+ menuItemFocusRing: {
+ position: 'absolute',
+ top: -FOCUS_RING_INSET,
+ left: -FOCUS_RING_INSET,
+ right: -FOCUS_RING_INSET,
+ bottom: -FOCUS_RING_INSET,
+ borderWidth: FOCUS_RING_THICKNESS,
+ pointerEvents: 'none',
+ },
+ triggerSlot: {
+ justifyContent: 'flex-start',
+ },
+ colorPlane: {
+ ...StyleSheet.absoluteFill,
+ pointerEvents: 'none',
+ },
+ iconStackContainer: {
+ flex: 1,
+ pointerEvents: 'none',
+ },
+ iconStack: {
+ ...StyleSheet.absoluteFill,
+ alignItems: 'center',
+ justifyContent: 'center',
+ pointerEvents: 'none',
+ },
+ pointerEventsAuto: {
+ pointerEvents: 'auto',
+ },
+ pointerEventsNone: {
+ pointerEvents: 'none',
+ },
+ pointerEventsBoxNone: {
+ pointerEvents: 'box-none',
+ },
+});
+
+export default FloatingActionButtonMenu;
+
+// @component-docs ignore-next-line
+export { FloatingActionButtonMenu };
diff --git a/src/components/FAB/index.ts b/src/components/FAB/index.ts
deleted file mode 100644
index 4364bc7411..0000000000
--- a/src/components/FAB/index.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import FABComponent from './FAB';
-import FABGroup from './FABGroup';
-
-const FAB = Object.assign(
- // @component ./FAB.tsx
- FABComponent,
- {
- // @component ./FABGroup.tsx
- Group: FABGroup,
- }
-);
-
-export default FAB;
diff --git a/src/components/FAB/tokens.ts b/src/components/FAB/tokens.ts
new file mode 100644
index 0000000000..0327eadc65
--- /dev/null
+++ b/src/components/FAB/tokens.ts
@@ -0,0 +1,128 @@
+import type { ViewStyle } from 'react-native';
+
+import { tokens } from '../../theme/tokens';
+import type {
+ ColorRole,
+ Elevation,
+ ThemeShapeCorners,
+ TypescaleKey,
+} from '../../theme/types';
+
+export type FloatingActionButtonVariant =
+ | 'primary'
+ | 'secondary'
+ | 'tertiary'
+ | 'tonalPrimary'
+ | 'tonalSecondary'
+ | 'tonalTertiary';
+
+export type FloatingActionButtonSize = 'default' | 'medium' | 'large';
+
+type SizeSpec = {
+ container: number;
+ icon: number;
+ shape: keyof ThemeShapeCorners;
+ leading: number;
+ trailing: number;
+ iconLabelGap: number;
+ labelTypescale: TypescaleKey;
+};
+
+const sizes = {
+ default: {
+ container: 56,
+ icon: 24,
+ shape: 'large',
+ leading: 16,
+ trailing: 16,
+ iconLabelGap: 8,
+ labelTypescale: 'titleMedium',
+ },
+ medium: {
+ container: 80,
+ icon: 28,
+ shape: 'largeIncreased',
+ leading: 26,
+ trailing: 26,
+ iconLabelGap: 12,
+ labelTypescale: 'titleLarge',
+ },
+ large: {
+ container: 96,
+ icon: 36,
+ shape: 'extraLarge',
+ // (container - icon) / 2 keeps the icon centered inside the visible
+ // square when the Extended FAB collapses to icon-only width.
+ leading: 30,
+ trailing: 30,
+ iconLabelGap: 16,
+ labelTypescale: 'headlineSmall',
+ },
+} as const satisfies Record;
+
+const stateElevation = {
+ enabled: 3,
+ hover: 4,
+ focus: 3,
+ pressed: 3,
+} as const satisfies Record;
+
+const variants = {
+ primary: { container: 'primary', content: 'onPrimary' },
+ secondary: { container: 'secondary', content: 'onSecondary' },
+ tertiary: { container: 'tertiary', content: 'onTertiary' },
+ tonalPrimary: {
+ container: 'primaryContainer',
+ content: 'onPrimaryContainer',
+ },
+ tonalSecondary: {
+ container: 'secondaryContainer',
+ content: 'onSecondaryContainer',
+ },
+ tonalTertiary: {
+ container: 'tertiaryContainer',
+ content: 'onTertiaryContainer',
+ },
+} as const satisfies Record<
+ FloatingActionButtonVariant,
+ { container: ColorRole; content: ColorRole }
+>;
+
+export const FloatingActionButtonTokens = {
+ sizes,
+ stateElevation,
+ variants,
+};
+
+const closeButton = {
+ container: 56,
+ iconSize: 20,
+ shape: 'full',
+} as const;
+
+const listItem = {
+ height: 56,
+ shape: 'full',
+ iconSize: 24,
+ leading: 24,
+ trailing: 24,
+ iconLabelGap: 8,
+} as const;
+
+const spacing = {
+ betweenItems: 4,
+ closeToLastItem: 8,
+} as const;
+
+export const FloatingActionButtonMenuTokens = {
+ closeButton,
+ listItem,
+ spacing,
+};
+
+const focusIndicator = tokens.md.sys.state.focusIndicator;
+export const FOCUS_RING_THICKNESS = focusIndicator.thickness;
+export const FOCUS_RING_OUTER_OFFSET = focusIndicator.outerOffset;
+export const FOCUS_RING_INSET = FOCUS_RING_OUTER_OFFSET + FOCUS_RING_THICKNESS;
+
+export const webNoOutline = { outline: 'none' } as unknown as ViewStyle;
diff --git a/src/components/FAB/useFabVisibility.ts b/src/components/FAB/useFabVisibility.ts
new file mode 100644
index 0000000000..a3cb6f89ce
--- /dev/null
+++ b/src/components/FAB/useFabVisibility.ts
@@ -0,0 +1,95 @@
+import * as React from 'react';
+import { Platform, type ViewStyle } from 'react-native';
+
+import {
+ useAnimatedStyle,
+ useSharedValue,
+ withSpring,
+ type AnimatedStyle,
+ type SharedValue,
+} from 'react-native-reanimated';
+
+import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext';
+import {
+ androidElevationLevels,
+ shadowLayers,
+} from '../../theme/tokens/sys/elevation';
+import { toRawSpring } from '../../theme/tokens/sys/motion';
+import type { Elevation, InternalTheme } from '../../types';
+
+type UseFabVisibilityArgs = {
+ visible: boolean;
+ theme: InternalTheme;
+ initialScale?: number;
+ transformOrigin?: ViewStyle['transformOrigin'];
+ /**
+ * Elevation level when shown. Shadow fades in/out with the FAB.
+ */
+ elevation?: Elevation;
+};
+
+type UseFabVisibilityResult = {
+ scale: SharedValue;
+ alpha: SharedValue;
+ transformOrigin: ViewStyle['transformOrigin'];
+ shadowStyle: AnimatedStyle;
+};
+
+const isAndroid = Platform.OS === 'android';
+
+/**
+ * Animates a FAB in and out: scale + alpha together.
+ * Reduce-motion: snap to the final value, no animation.
+ *
+ * Returns `shadowStyle` too. Put it on the same view as the transform so the
+ * shadow stays in sync (Android uses `elevation`, iOS/web uses `shadow*`).
+ */
+export function useFabVisibility({
+ visible,
+ theme,
+ initialScale = 0,
+ transformOrigin = 'center',
+ elevation = 0,
+}: UseFabVisibilityArgs): UseFabVisibilityResult {
+ const reduceMotion = useReduceMotion();
+ const scale = useSharedValue(visible ? 1 : initialScale);
+ const alpha = useSharedValue(visible ? 1 : 0);
+
+ React.useEffect(() => {
+ const targetScale = visible ? 1 : initialScale;
+ const targetAlpha = visible ? 1 : 0;
+ if (reduceMotion) {
+ scale.value = targetScale;
+ alpha.value = targetAlpha;
+ return;
+ }
+ scale.value = withSpring(
+ targetScale,
+ toRawSpring(theme.motion.spring.fast.spatial)
+ );
+ alpha.value = withSpring(
+ targetAlpha,
+ toRawSpring(theme.motion.spring.fast.effects)
+ );
+ }, [visible, theme, reduceMotion, scale, alpha, initialScale]);
+
+ const restingElevationDp = androidElevationLevels[elevation];
+ const restingShadowOpacity = elevation ? shadowLayers[0].shadowOpacity : 0;
+ const shadowOffsetHeight = shadowLayers[0].height[elevation];
+ const shadowRadius = shadowLayers[0].shadowRadius[elevation];
+ const shadowColor = theme.colors.shadow;
+
+ const shadowStyle = useAnimatedStyle(() => {
+ if (isAndroid) {
+ return { elevation: alpha.value * restingElevationDp };
+ }
+ return {
+ shadowColor,
+ shadowOpacity: alpha.value * restingShadowOpacity,
+ shadowOffset: { width: 0, height: shadowOffsetHeight },
+ shadowRadius,
+ };
+ });
+
+ return { scale, alpha, transformOrigin, shadowStyle };
+}
diff --git a/src/components/FAB/useFocusRing.ts b/src/components/FAB/useFocusRing.ts
new file mode 100644
index 0000000000..bb056a10c8
--- /dev/null
+++ b/src/components/FAB/useFocusRing.ts
@@ -0,0 +1,41 @@
+import * as React from 'react';
+import { Platform } from 'react-native';
+
+import { useSharedValue, type SharedValue } from 'react-native-reanimated';
+
+export type FocusRingState = {
+ /**
+ * `true` when the surface is keyboard-focused. Drive the focus ring's
+ * `opacity` from this in a `useAnimatedStyle`.
+ */
+ focusedSV: SharedValue;
+ /** Wire to the `Pressable`/`TouchableRipple`'s `onFocus`. */
+ onFocus: () => void;
+ /** Wire to the `Pressable`/`TouchableRipple`'s `onBlur`. */
+ onBlur: () => void;
+};
+
+/**
+ * Drives an MD3 focus indicator for FAB-flavored surfaces. On web, focus is
+ * gated by `:focus-visible` so a mouse click does not light the ring; on
+ * native, every focus event is honored.
+ */
+export function useFocusRing(): FocusRingState {
+ const focusedSV = useSharedValue(false);
+
+ const onFocus = React.useCallback(() => {
+ if (
+ Platform.OS === 'web' &&
+ !document.activeElement?.matches(':focus-visible')
+ ) {
+ return;
+ }
+ focusedSV.value = true;
+ }, [focusedSV]);
+
+ const onBlur = React.useCallback(() => {
+ focusedSV.value = false;
+ }, [focusedSV]);
+
+ return { focusedSV, onFocus, onBlur };
+}
diff --git a/src/components/FAB/utils.ts b/src/components/FAB/utils.ts
index d46fcaf685..3cee69f370 100644
--- a/src/components/FAB/utils.ts
+++ b/src/components/FAB/utils.ts
@@ -1,347 +1,86 @@
-import { MutableRefObject } from 'react';
-import { Animated, ColorValue, Platform, ViewStyle } from 'react-native';
-
+import { RefObject } from 'react';
+import { ColorValue, Platform } from 'react-native';
+
+import {
+ FloatingActionButtonSize,
+ FloatingActionButtonTokens,
+ FloatingActionButtonVariant,
+} from './tokens';
+import type { TypescaleKey } from '../../theme/types';
+import { resolveCornerRadius, ShapeToken } from '../../theme/utils/shape';
import type { InternalTheme } from '../../types';
-type GetCombinedStylesProps = {
- isAnimatedFromRight: boolean;
- isIconStatic: boolean;
- isRTL: boolean;
- distance: number;
- animFAB: Animated.Value;
-};
-
-type CombinedStyles = {
- innerWrapper: Animated.WithAnimatedValue;
- iconWrapper: Animated.WithAnimatedValue;
- absoluteFill: Animated.WithAnimatedValue;
-};
-
-type Variant = 'primary' | 'secondary' | 'tertiary' | 'surface';
-
-type BaseProps = {
- isVariant: (variant: Variant) => boolean;
- theme: InternalTheme;
-};
-
-export const getCombinedStyles = ({
- isAnimatedFromRight,
- isIconStatic,
- isRTL,
- distance,
- animFAB,
-}: GetCombinedStylesProps): CombinedStyles => {
- const defaultPositionStyles = { left: -distance, right: undefined };
-
- const combinedStyles: CombinedStyles = {
- innerWrapper: {
- ...defaultPositionStyles,
- },
- iconWrapper: {
- ...defaultPositionStyles,
- },
- absoluteFill: {},
- };
-
- const animatedFromRight = isAnimatedFromRight && !isRTL;
- const animatedFromRightRTL = isAnimatedFromRight && isRTL;
- const animatedFromLeft = !isAnimatedFromRight && !isRTL;
- const animatedFromLeftRTL = !isAnimatedFromRight && isRTL;
-
- if (animatedFromRight) {
- combinedStyles.innerWrapper.transform = [
- {
- translateX: animFAB.interpolate({
- inputRange: [distance, 0],
- outputRange: [distance, 0],
- }),
- },
- ];
- combinedStyles.iconWrapper.transform = [
- {
- translateX: isIconStatic ? 0 : animFAB,
- },
- ];
- combinedStyles.absoluteFill.transform = [
- {
- translateX: animFAB.interpolate({
- inputRange: [distance, 0],
- outputRange: [Math.abs(distance) / 2, Math.abs(distance)],
- }),
- },
- ];
- } else if (animatedFromRightRTL) {
- combinedStyles.iconWrapper.transform = [
- {
- translateX: isIconStatic
- ? 0
- : animFAB.interpolate({
- inputRange: [distance, 0],
- outputRange: [-distance, 0],
- }),
- },
- ];
- combinedStyles.innerWrapper.transform = [
- {
- translateX: animFAB.interpolate({
- inputRange: [distance, 0],
- outputRange: [-distance, 0],
- }),
- },
- ];
- combinedStyles.absoluteFill.transform = [
- {
- translateX: animFAB.interpolate({
- inputRange: [distance, 0],
- outputRange: [0, distance],
- }),
- },
- ];
- } else if (animatedFromLeft) {
- combinedStyles.iconWrapper.transform = [
- {
- translateX: isIconStatic
- ? distance
- : animFAB.interpolate({
- inputRange: [0, distance],
- outputRange: [distance, distance * 2],
- }),
- },
- ];
- combinedStyles.innerWrapper.transform = [
- {
- translateX: animFAB,
- },
- ];
- combinedStyles.absoluteFill.transform = [
- {
- translateX: animFAB.interpolate({
- inputRange: [0, distance],
- outputRange: [0, Math.abs(distance) / 2],
- }),
- },
- ];
- } else if (animatedFromLeftRTL) {
- combinedStyles.iconWrapper.transform = [
- {
- translateX: isIconStatic
- ? animFAB.interpolate({
- inputRange: [0, distance],
- outputRange: [-distance, -distance * 2],
- })
- : -distance,
- },
- ];
- combinedStyles.innerWrapper.transform = [
- {
- translateX: animFAB.interpolate({
- inputRange: [0, distance],
- outputRange: [0, -distance],
- }),
- },
- ];
- combinedStyles.absoluteFill.transform = [
- {
- translateX: animFAB.interpolate({
- inputRange: [0, distance],
- outputRange: [0, -distance],
- }),
- },
- ];
- }
-
- return combinedStyles;
-};
-
-const getBackgroundColor = ({
- theme,
- isVariant,
- customBackgroundColor,
-}: BaseProps & { customBackgroundColor?: ColorValue }) => {
- if (customBackgroundColor) {
- return customBackgroundColor;
- }
-
- if (isVariant('primary')) {
- return theme.colors.primaryContainer;
- }
-
- if (isVariant('secondary')) {
- return theme.colors.secondaryContainer;
- }
-
- if (isVariant('tertiary')) {
- return theme.colors.tertiaryContainer;
- }
-
- if (isVariant('surface')) {
- return theme.colors.surfaceContainerHigh;
- }
-
- return theme.colors.primaryContainer;
+export type ResolvedColors = {
+ container: ColorValue;
+ content: ColorValue;
};
-const getForegroundColor = ({
+/**
+ * Resolve container + content colors. Explicit overrides win; when only
+ * `containerColor` is set, the content color is derived via
+ * `contentColorFor`.
+ */
+export const resolveColors = ({
theme,
- isVariant,
- customColor,
-}: BaseProps & { customColor?: ColorValue }) => {
- if (typeof customColor !== 'undefined') {
- return customColor;
- }
-
- if (isVariant('primary')) {
- return theme.colors.onPrimaryContainer;
- }
-
- if (isVariant('secondary')) {
- return theme.colors.onSecondaryContainer;
- }
-
- if (isVariant('tertiary')) {
- return theme.colors.onTertiaryContainer;
- }
-
- if (isVariant('surface')) {
- return theme.colors.primary;
- }
-
- return theme.colors.onPrimaryContainer;
-};
-
-export const getFABColors = ({
- theme,
- variant,
- customColor,
- customBackgroundColor,
+ variant = 'tonalPrimary',
}: {
theme: InternalTheme;
- variant: string;
- customColor?: ColorValue;
- customBackgroundColor?: ColorValue;
-}) => {
- const isVariant = (variantToCompare: Variant) => {
- return variant === variantToCompare;
- };
-
- const baseFABColorProps = { theme, isVariant };
-
- const backgroundColor = getBackgroundColor({
- ...baseFABColorProps,
- customBackgroundColor,
- });
-
- const foregroundColor = getForegroundColor({
- ...baseFABColorProps,
- customColor,
- });
-
- return {
- backgroundColor,
- foregroundColor,
- };
-};
-
-const getLabelColor = ({ theme }: { theme: InternalTheme }) => {
- return theme.colors.onSurface;
-};
-
-const getStackedFABBackgroundColor = ({ theme }: { theme: InternalTheme }) => {
- return theme.colors.surfaceContainerHigh;
-};
-
-export const getFABGroupColors = ({
+ variant?: FloatingActionButtonVariant;
+ containerColor?: ColorValue;
+ contentColor?: ColorValue;
+}): ResolvedColors => {
+ const roles = FloatingActionButtonTokens.variants[variant];
+ const container = theme.colors[roles.container];
+ const content = theme.colors[roles.content];
+ return { container, content };
+};
+
+export type Dimensions = {
+ height: number;
+ width: number;
+ borderRadius: number;
+ iconSize: number;
+ leading: number;
+ trailing: number;
+ iconLabelGap: number;
+ labelTypescale: TypescaleKey;
+};
+
+/**
+ * Resolve geometry for a FAB at a given size, with optional shape, icon
+ * size, and leading/trailing overrides for FAB Menu items and the close
+ * button.
+ */
+export const getDimensions = ({
theme,
- customBackdropColor,
+ size = 'default',
+ shape,
+ iconSize,
+ leading,
+ trailing,
}: {
theme: InternalTheme;
- customBackdropColor?: ColorValue;
-}) => {
+ size?: FloatingActionButtonSize;
+ shape?: ShapeToken;
+ iconSize?: number;
+ leading?: number;
+ trailing?: number;
+}): Dimensions => {
+ const spec = FloatingActionButtonTokens.sizes[size];
+ const shapeToken: ShapeToken = shape ?? spec.shape;
return {
- labelColor: getLabelColor({ theme }),
- backdropColor: customBackdropColor ?? theme.colors.background,
- backdropOpacity: customBackdropColor ? 1 : 0.95,
- stackedFABBackgroundColor: getStackedFABBackgroundColor({ theme }),
+ height: spec.container,
+ width: spec.container,
+ borderRadius: resolveCornerRadius(theme, shapeToken),
+ iconSize: iconSize ?? spec.icon,
+ leading: leading ?? spec.leading,
+ trailing: trailing ?? spec.trailing,
+ iconLabelGap: spec.iconLabelGap,
+ labelTypescale: spec.labelTypescale,
};
};
-const v3SmallSize = {
- height: 40,
- width: 40,
-};
-const v3MediumSize = {
- height: 56,
- width: 56,
-};
-const v3LargeSize = {
- height: 96,
- width: 96,
-};
-
-const getCustomFabSize = (customSize: number) => ({
- height: customSize,
- width: customSize,
- borderRadius: customSize / 4,
-});
-
-export const getFabStyle = ({
- size,
- theme,
- customSize,
-}: {
- customSize?: number;
- size: 'small' | 'medium' | 'large';
- theme: InternalTheme;
-}) => {
- if (customSize) return getCustomFabSize(customSize);
-
- switch (size) {
- case 'small':
- return { ...v3SmallSize, borderRadius: theme.shapes.corner.medium };
- case 'medium':
- return { ...v3MediumSize, borderRadius: theme.shapes.corner.large };
- case 'large':
- return { ...v3LargeSize, borderRadius: theme.shapes.corner.extraLarge };
- }
-};
-
-const v3Extended = {
- height: 56,
- borderRadius: 16,
- paddingHorizontal: 16,
-};
-
-const getExtendedFabDimensions = (customSize: number) => ({
- height: customSize,
- paddingHorizontal: 16,
-});
-
-export const getExtendedFabStyle = ({
- customSize,
- theme: _theme,
-}: {
- customSize?: number;
- theme: InternalTheme;
-}) => {
- if (customSize) return getExtendedFabDimensions(customSize);
-
- return v3Extended;
-};
-
-let cachedContext: CanvasRenderingContext2D | null = null;
-
-const getCanvasContext = () => {
- if (cachedContext) {
- return cachedContext;
- }
-
- const canvas = document.createElement('canvas');
- cachedContext = canvas.getContext('2d');
-
- return cachedContext;
-};
-
-export const getLabelSizeWeb = (ref: MutableRefObject) => {
+export const getLabelSizeWeb = (ref: RefObject) => {
if (Platform.OS !== 'web' || ref.current === null) {
return null;
}
@@ -364,3 +103,16 @@ export const getLabelSizeWeb = (ref: MutableRefObject) => {
(metrics.fontBoundingBoxDescent ?? 0),
};
};
+
+let cachedContext: CanvasRenderingContext2D | null = null;
+
+const getCanvasContext = () => {
+ if (cachedContext) {
+ return cachedContext;
+ }
+
+ const canvas = document.createElement('canvas');
+ cachedContext = canvas.getContext('2d');
+
+ return cachedContext;
+};
diff --git a/src/components/__tests__/AnimatedFAB.test.tsx b/src/components/__tests__/AnimatedFAB.test.tsx
deleted file mode 100644
index 318f59c3b9..0000000000
--- a/src/components/__tests__/AnimatedFAB.test.tsx
+++ /dev/null
@@ -1,183 +0,0 @@
-///
-
-import * as React from 'react';
-import { Animated, StyleSheet } from 'react-native';
-
-import { fireEvent } from '@testing-library/react-native';
-import { act } from 'react-test-renderer';
-
-import { render } from '../../test-utils';
-import { Palette } from '../../theme/tokens';
-import AnimatedFAB from '../FAB/AnimatedFAB';
-
-const styles = StyleSheet.create({
- background: {
- backgroundColor: Palette.tertiary50,
- },
-});
-
-it('renders animated fab', () => {
- const tree = render(
- {}} label="" extended={false} icon="plus" />
- ).toJSON();
-
- expect(tree).toMatchSnapshot();
-});
-
-it('renders animated fab with label on the right by default', () => {
- const tree = render(
- {}} icon="plus" />
- ).toJSON();
-
- expect(tree).toMatchSnapshot();
-});
-
-it('renders animated fab with label on the left', () => {
- const tree = render(
- {}}
- icon="plus"
- />
- ).toJSON();
-
- expect(tree).toMatchSnapshot();
-});
-
-it('renders animated fab with only transparent container', () => {
- const { getByTestId } = render(
- {}}
- icon="plus"
- testID="animated-fab"
- extended={false}
- style={styles.background}
- />
- );
- expect(getByTestId('animated-fab-container')).toHaveStyle({
- backgroundColor: 'transparent',
- });
-});
-
-it('animated value changes correctly', () => {
- const value = new Animated.Value(1);
- const { getByTestId } = render(
-
- );
- expect(getByTestId('animated-fab-container-outer-layer')).toHaveStyle({
- transform: [{ scale: 1 }],
- });
-
- Animated.timing(value, {
- toValue: 1.5,
- useNativeDriver: false,
- duration: 200,
- }).start();
-
- act(() => {
- jest.advanceTimersByTime(200);
- });
-
- expect(getByTestId('animated-fab-container-outer-layer')).toHaveStyle({
- transform: [{ scale: 1.5 }],
- });
-});
-
-it('renders FAB without uppercase styling if uppercase prop is falsy', () => {
- const { getByTestId } = render(
- {}}
- icon="plus"
- testID="animated-fab"
- style={styles.background}
- extended={false}
- uppercase={false}
- />
- );
-
- expect(getByTestId('animated-fab-text')).not.toHaveStyle({
- textTransform: 'uppercase',
- });
-});
-
-it('renders FAB with uppercase styling if uppercase prop is truthy', () => {
- const { getByTestId } = render(
- {}}
- icon="plus"
- testID="animated-fab"
- style={styles.background}
- extended={false}
- uppercase
- />
- );
-
- expect(getByTestId('animated-fab-text')).toHaveStyle({
- textTransform: 'uppercase',
- });
-});
-
-it('renders correct elevation value for shadow views', () => {
- const { getByTestId } = render(
- {}}
- icon="plus"
- testID="animated-fab"
- />
- );
-
- expect(getByTestId('animated-fab-shadow')).toHaveStyle({ elevation: 3 });
- expect(getByTestId('animated-fab-extended-shadow')).toHaveStyle({
- elevation: 3,
- });
-});
-
-describe('AnimatedFAB events', () => {
- it('onPress passes event', () => {
- const onPress = jest.fn();
- const { getByTestId } = render(
-
- );
-
- act(() => {
- fireEvent(getByTestId('animated-fab'), 'onPress', { key: 'value' });
- });
-
- expect(onPress).toHaveBeenCalledWith({ key: 'value' });
- });
-
- it('onLongPress passes event', () => {
- const onLongPress = jest.fn();
- const { getByTestId } = render(
-
- );
-
- act(() => {
- fireEvent(getByTestId('animated-fab'), 'onLongPress', { key: 'value' });
- });
-
- expect(onLongPress).toHaveBeenCalledWith({ key: 'value' });
- });
-});
diff --git a/src/components/__tests__/FAB.test.tsx b/src/components/__tests__/FAB.test.tsx
deleted file mode 100644
index e99c694885..0000000000
--- a/src/components/__tests__/FAB.test.tsx
+++ /dev/null
@@ -1,355 +0,0 @@
-import * as React from 'react';
-import { Animated, StyleSheet } from 'react-native';
-
-import { fireEvent } from '@testing-library/react-native';
-import { act } from 'react-test-renderer';
-
-import { getTheme } from '../../core/theming';
-import { render } from '../../test-utils';
-import FAB from '../FAB';
-import { getFABColors } from '../FAB/utils';
-
-const styles = StyleSheet.create({
- borderRadius: {
- borderRadius: 0,
- },
- small: {
- height: 40,
- width: 40,
- borderRadius: 12,
- },
- medium: {
- height: 56,
- width: 56,
- borderRadius: 16,
- },
- large: {
- height: 96,
- width: 96,
- borderRadius: 28,
- },
-});
-
-it('renders default FAB', () => {
- const tree = render( {}} icon="plus" />).toJSON();
-
- expect(tree).toMatchSnapshot();
-});
-
-it('renders small FAB', () => {
- const tree = render(
- {}} icon="plus" />
- ).toJSON();
-
- expect(tree).toMatchSnapshot();
-});
-
-it('renders large FAB', () => {
- const tree = render(
- {}} icon="plus" />
- ).toJSON();
-
- expect(tree).toMatchSnapshot();
-});
-
-it('renders FAB with custom size prop', () => {
- const tree = render(
- {}} icon="plus" />
- ).toJSON();
-
- expect(tree).toMatchSnapshot();
-});
-
-it('renders extended FAB', () => {
- const tree = render(
- {}} icon="plus" label="Add items" />
- ).toJSON();
-
- expect(tree).toMatchSnapshot();
-});
-
-it('renders extended FAB with custom size prop', () => {
- const tree = render(
- {}} icon="plus" label="Add items" />
- ).toJSON();
-
- expect(tree).toMatchSnapshot();
-});
-
-it('renders loading FAB', () => {
- const tree = render(
- {}} icon="plus" loading={true} />
- ).toJSON();
-
- expect(tree).toMatchSnapshot();
-});
-
-it('renders loading FAB with custom size prop', () => {
- const tree = render(
- {}} icon="plus" loading={true} />
- ).toJSON();
-
- expect(tree).toMatchSnapshot();
-});
-
-it('renders custom color for the icon and label of the FAB', () => {
- const tree = render(
- {}} icon="plus" color="#AA0114" />
- ).toJSON();
-
- expect(tree).toMatchSnapshot();
-});
-
-it('renders not visible FAB', () => {
- const { update, toJSON } = render( {}} icon="plus" />);
- update( {}} icon="plus" visible={false} />);
-
- expect(toJSON()).toMatchSnapshot();
-});
-
-it('renders visible FAB', () => {
- const { update, toJSON } = render(
- {}} icon="plus" visible={false} />
- );
-
- update( {}} icon="plus" visible={true} />);
-
- expect(toJSON()).toMatchSnapshot();
-});
-
-it('renders FAB with custom border radius', () => {
- const { getByTestId } = render(
- {}}
- icon="plus"
- testID="fab"
- style={styles.borderRadius}
- />
- );
-
- expect(getByTestId('fab-container')).toHaveStyle({ borderRadius: 0 });
-});
-
-it('renders FAB with zero border radius', () => {
- const { getByTestId } = render(
- {}}
- icon="plus"
- testID="fab"
- />
- );
-
- expect(getByTestId('fab-container')).toHaveStyle({ borderRadius: 0 });
-});
-
-it('renders FAB without uppercase styling by default', () => {
- const { getByTestId } = render(
- {}} label="Add items" testID="fab" />
- );
-
- expect(getByTestId('fab-text')).not.toHaveStyle({
- textTransform: 'uppercase',
- });
-});
-
-it('renders FAB without uppercase styling if uppercase prop is falsy', () => {
- const { getByTestId } = render(
- {}} label="Add items" testID="fab" uppercase={false} />
- );
-
- expect(getByTestId('fab-text')).not.toHaveStyle({
- textTransform: 'uppercase',
- });
-});
-
-it('renders FAB with uppercase styling if uppercase prop is truthy', () => {
- const { getByTestId } = render(
- {}} label="Add items" testID="fab" uppercase />
- );
-
- expect(getByTestId('fab-text')).toHaveStyle({
- textTransform: 'uppercase',
- });
-});
-
-(['small', 'medium', 'large'] as const).forEach((size) => {
- it(`renders ${size} FAB with correct size and border radius`, () => {
- const { getByTestId } = render(
- {}} size={size} icon="plus" testID={`${size}-fab`} />
- );
-
- expect(getByTestId(`${size}-fab-content`)).toHaveStyle(styles[size]);
- });
-});
-
-describe('getFABColors - background color', () => {
- it('should return color from styles', () => {
- expect(
- getFABColors({
- theme: getTheme(),
- variant: 'primary',
- customBackgroundColor: 'purple',
- })
- ).toMatchObject({
- backgroundColor: 'purple',
- });
- });
-
- it('should return correct theme color, primary variant', () => {
- expect(
- getFABColors({
- theme: getTheme(),
- variant: 'primary',
- })
- ).toMatchObject({
- backgroundColor: getTheme().colors.primaryContainer,
- });
- });
-
- it('should return correct theme color, for theme version 3, secondary variant', () => {
- expect(
- getFABColors({
- theme: getTheme(),
- variant: 'secondary',
- })
- ).toMatchObject({
- backgroundColor: getTheme().colors.secondaryContainer,
- });
- });
-
- it('should return correct theme color, for theme version 3, tertiary variant', () => {
- expect(
- getFABColors({
- theme: getTheme(),
- variant: 'tertiary',
- })
- ).toMatchObject({
- backgroundColor: getTheme().colors.tertiaryContainer,
- });
- });
-
- it('should return correct theme color, for theme version 3, surface variant', () => {
- expect(
- getFABColors({
- theme: getTheme(),
- variant: 'surface',
- })
- ).toMatchObject({
- backgroundColor: getTheme().colors.surfaceContainerHigh,
- });
- });
-});
-
-describe('getFABColors - foreground color', () => {
- it('should return custom color', () => {
- expect(
- getFABColors({
- theme: getTheme(),
- variant: 'primary',
- customColor: 'purple',
- })
- ).toMatchObject({
- foregroundColor: 'purple',
- });
- });
-
- it('should return correct theme color, primary variant', () => {
- expect(
- getFABColors({
- theme: getTheme(),
- variant: 'primary',
- })
- ).toMatchObject({
- foregroundColor: getTheme().colors.onPrimaryContainer,
- });
- });
-
- it('should return correct theme color, for theme version 3, secondary variant', () => {
- expect(
- getFABColors({
- theme: getTheme(),
- variant: 'secondary',
- })
- ).toMatchObject({
- foregroundColor: getTheme().colors.onSecondaryContainer,
- });
- });
-
- it('should return correct theme color, for theme version 3, tertiary variant', () => {
- expect(
- getFABColors({
- theme: getTheme(),
- variant: 'tertiary',
- })
- ).toMatchObject({
- foregroundColor: getTheme().colors.onTertiaryContainer,
- });
- });
-
- it('should return correct theme color, for theme version 3, surface variant', () => {
- expect(
- getFABColors({
- theme: getTheme(),
- variant: 'surface',
- })
- ).toMatchObject({
- foregroundColor: getTheme().colors.primary,
- });
- });
-});
-
-it('animated value changes correctly', () => {
- const value = new Animated.Value(1);
- const { getByTestId } = render(
- {}}
- icon="plus"
- testID="fab"
- style={[{ transform: [{ scale: value }] }]}
- />
- );
- expect(getByTestId('fab-container-outer-layer')).toHaveStyle({
- transform: [{ scale: 1 }],
- });
-
- Animated.timing(value, {
- toValue: 1.5,
- useNativeDriver: false,
- duration: 200,
- }).start();
-
- act(() => {
- jest.advanceTimersByTime(200);
- });
- expect(getByTestId('fab-container-outer-layer')).toHaveStyle({
- transform: [{ scale: 1.5 }],
- });
-});
-
-describe('FAB events', () => {
- it('onPress passes event', () => {
- const onPress = jest.fn();
- const { getByText } = render();
-
- act(() => {
- fireEvent(getByText('Add items'), 'onPress', { key: 'value' });
- });
-
- expect(onPress).toHaveBeenCalledWith({ key: 'value' });
- });
-
- it('onLongPress passes event', () => {
- const onLongPress = jest.fn();
- const { getByText } = render(
-
- );
-
- act(() => {
- fireEvent(getByText('Add items'), 'onLongPress', { key: 'value' });
- });
-
- expect(onLongPress).toHaveBeenCalledWith({ key: 'value' });
- });
-});
diff --git a/src/components/__tests__/FABGroup.test.tsx b/src/components/__tests__/FABGroup.test.tsx
deleted file mode 100644
index f90e534910..0000000000
--- a/src/components/__tests__/FABGroup.test.tsx
+++ /dev/null
@@ -1,417 +0,0 @@
-import * as React from 'react';
-import { Animated } from 'react-native';
-
-import { act, fireEvent } from '@testing-library/react-native';
-
-import { getTheme } from '../../core/theming';
-import { render } from '../../test-utils';
-import FAB from '../FAB';
-import { getFABGroupColors } from '../FAB/utils';
-
-describe('getFABGroupColors - backdrop color', () => {
- it('should return custom color', () => {
- expect(
- getFABGroupColors({
- theme: getTheme(),
- customBackdropColor: 'transparent',
- })
- ).toMatchObject({
- backdropColor: 'transparent',
- });
- });
-
- it('should return correct backdrop color, for theme version 3', () => {
- expect(
- getFABGroupColors({
- theme: getTheme(),
- })
- ).toMatchObject({
- backdropColor: getTheme().colors.background,
- });
- });
-});
-
-describe('getFABGroupColors - backdrop opacity', () => {
- it('should return scrimAlpha when no custom backdrop color', () => {
- expect(
- getFABGroupColors({
- theme: getTheme(),
- })
- ).toMatchObject({
- backdropOpacity: 0.95,
- });
- });
-
- it('should return 1 when custom backdrop color is provided', () => {
- expect(
- getFABGroupColors({
- theme: getTheme(),
- customBackdropColor: 'transparent',
- })
- ).toMatchObject({
- backdropOpacity: 1,
- });
- });
-});
-
-describe('getFABGroupColors - label color', () => {
- it('should return correct theme color, for theme version 3', () => {
- expect(
- getFABGroupColors({
- theme: getTheme(),
- })
- ).toMatchObject({
- labelColor: getTheme().colors.onSurface,
- });
- });
-});
-
-describe('getFABGroupColors - stacked FAB background color', () => {
- it('should return correct theme color, for theme version 3', () => {
- expect(
- getFABGroupColors({
- theme: getTheme(),
- })
- ).toMatchObject({
- stackedFABBackgroundColor: getTheme().colors.surfaceContainerHigh,
- });
- });
-});
-
-describe('FABActions - labelStyle - containerStyle', () => {
- it('correctly applies label style', () => {
- const { getByText } = render(
- {}}
- actions={[
- {
- label: 'complete',
- labelStyle: {
- fontSize: 24,
- fontWeight: '500',
- },
- onPress() {},
- icon: '',
- },
- ]}
- />
- );
-
- expect(getByText('complete')).toHaveStyle({
- fontSize: 24,
- fontWeight: '500',
- });
- });
-
- it('correctly applies containerStyle style', () => {
- const { getByA11yHint } = render(
- {}}
- actions={[
- {
- label: 'remove',
- accessibilityHint: 'hint',
- containerStyle: {
- padding: 16,
- backgroundColor: '#687456',
- marginLeft: 16,
- },
- onPress() {},
- icon: '',
- },
- ]}
- />
- );
-
- expect(getByA11yHint('hint')).toHaveStyle({
- padding: 16,
- backgroundColor: '#687456',
- });
- });
-});
-
-it('correctly adds label prop', () => {
- const { getByText } = render(
- {}}
- actions={[
- {
- label: 'testing',
- onPress() {},
- icon: '',
- },
- ]}
- />
- );
-
- expect(getByText('Label test')).toBeTruthy();
-});
-
-it('animated value changes correctly', () => {
- const value = new Animated.Value(1);
- const { getByTestId } = render(
- {}}
- testID="my-fab"
- fabStyle={[{ transform: [{ scale: value }] }]}
- actions={[
- {
- label: 'testing',
- onPress() {},
- icon: '',
- },
- ]}
- />
- );
- expect(getByTestId('my-fab-container-outer-layer')).toHaveStyle({
- transform: [{ scale: 1 }],
- });
-
- Animated.timing(value, {
- toValue: 1.5,
- useNativeDriver: false,
- duration: 200,
- }).start();
-
- act(() => {
- jest.advanceTimersByTime(200);
- });
- expect(getByTestId('my-fab-container-outer-layer')).toHaveStyle({
- transform: [{ scale: 1.5 }],
- });
-});
-
-describe('FAB.Group events', () => {
- it('onPress passes event', () => {
- const onPress = jest.fn();
- const { getByText } = render(
-
- );
-
- act(() => {
- fireEvent(getByText('Stack test'), 'onPress', { key: 'value' });
- });
-
- expect(onPress).toHaveBeenCalledWith({ key: 'value' });
- });
-
- it('onLongPress passes event', () => {
- const onLongPress = jest.fn();
- const { getByText } = render(
-
- );
-
- act(() => {
- fireEvent(getByText('Stack test'), 'onLongPress', { key: 'value' });
- });
-
- expect(onLongPress).toHaveBeenCalledWith({ key: 'value' });
- });
-});
-
-describe('Toggle Stack visibility', () => {
- it('toggles stack visibility on press', () => {
- const onStateChange = jest.fn();
- const { getByText } = render(
-
- );
-
- act(() => {
- fireEvent(getByText('Stack test'), 'onPress');
- });
-
- expect(onStateChange).toHaveBeenCalledTimes(1);
- });
-
- it('does not toggle stack visibility on long press', () => {
- const onStateChange = jest.fn();
- const { getByText } = render(
-
- );
-
- act(() => {
- fireEvent(getByText('Stack test'), 'onLongPress');
- });
-
- expect(onStateChange).toHaveBeenCalledTimes(0);
- });
-
- it('toggles stack visibility on long press with toggleStackOnLongPress prop', () => {
- const onStateChange = jest.fn();
- const { getByText } = render(
-
- );
-
- act(() => {
- fireEvent(getByText('Stack test'), 'onLongPress');
- });
-
- expect(onStateChange).toHaveBeenCalledTimes(1);
- });
-
- it('does not toggle stack visibility on press with toggleStackOnLongPress prop', () => {
- const onStateChange = jest.fn();
- const { getByText } = render(
-
- );
-
- act(() => {
- fireEvent(getByText('Stack test'), 'onPress');
- });
-
- expect(onStateChange).toHaveBeenCalledTimes(0);
- });
-
- it('does not trigger onLongPress when stack is opened', () => {
- const onStateChange = jest.fn();
- const onLongPress = jest.fn();
- const { getByText } = render(
-
- );
-
- act(() => {
- fireEvent(getByText('Stack test'), 'onLongPress');
- });
-
- expect(onLongPress).toHaveBeenCalledTimes(0);
- });
-
- it('does trigger onLongPress when stack is opened and enableLongPressWhenStackOpened is true', () => {
- const onStateChange = jest.fn();
- const onLongPress = jest.fn();
- const { getByText } = render(
-
- );
-
- act(() => {
- fireEvent(getByText('Stack test'), 'onLongPress');
- });
-
- expect(onLongPress).toHaveBeenCalledTimes(1);
- });
-});
diff --git a/src/components/__tests__/__snapshots__/AnimatedFAB.test.tsx.snap b/src/components/__tests__/__snapshots__/AnimatedFAB.test.tsx.snap
deleted file mode 100644
index fad6addacc..0000000000
--- a/src/components/__tests__/__snapshots__/AnimatedFAB.test.tsx.snap
+++ /dev/null
@@ -1,866 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`renders animated fab 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- plus
-
-
-
-
-
-
-
-`;
-
-exports[`renders animated fab with label on the left 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- plus
-
-
-
-
- text
-
-
-
-
-`;
-
-exports[`renders animated fab with label on the right by default 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- plus
-
-
-
-
- text
-
-
-
-
-`;
diff --git a/src/components/__tests__/__snapshots__/FAB.test.tsx.snap b/src/components/__tests__/__snapshots__/FAB.test.tsx.snap
deleted file mode 100644
index 386ce68373..0000000000
--- a/src/components/__tests__/__snapshots__/FAB.test.tsx.snap
+++ /dev/null
@@ -1,2258 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`renders FAB with custom size prop 1`] = `
-
-
-
-
-
-
-
- plus
-
-
-
-
-
-
-
-`;
-
-exports[`renders custom color for the icon and label of the FAB 1`] = `
-
-
-
-
-
-
-
- plus
-
-
-
-
-
-
-
-`;
-
-exports[`renders default FAB 1`] = `
-
-
-
-
-
-
-
- plus
-
-
-
-
-
-
-
-`;
-
-exports[`renders extended FAB 1`] = `
-
-
-
-
-
-
-
- plus
-
-
-
-
- Add items
-
-
-
-
-
-`;
-
-exports[`renders extended FAB with custom size prop 1`] = `
-
-
-
-
-
-
-
- plus
-
-
-
-
- Add items
-
-
-
-
-
-`;
-
-exports[`renders large FAB 1`] = `
-
-
-
-
-
-
-
- plus
-
-
-
-
-
-
-
-`;
-
-exports[`renders loading FAB 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`renders loading FAB with custom size prop 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`renders not visible FAB 1`] = `
-
-
-
-
-
-
-
- plus
-
-
-
-
-
-
-
-`;
-
-exports[`renders small FAB 1`] = `
-
-
-
-
-
-
-
- plus
-
-
-
-
-
-
-
-`;
-
-exports[`renders visible FAB 1`] = `
-
-
-
-
-
-
-
- plus
-
-
-
-
-
-
-
-`;
diff --git a/src/index.tsx b/src/index.tsx
index b5ecadea41..5713430e35 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -21,8 +21,6 @@ import * as List from './components/List/List';
export { Avatar, List, Drawer };
-export * from './components/FAB/AnimatedFAB';
-
export { default as Badge } from './components/Badge';
export { default as ActivityIndicator } from './components/ActivityIndicator';
export { default as Banner } from './components/Banner';
@@ -34,8 +32,9 @@ export { default as Chip } from './components/Chip/Chip';
export { default as DataTable } from './components/DataTable/DataTable';
export { default as Dialog } from './components/Dialog/Dialog';
export { default as Divider } from './components/Divider';
-export { default as FAB } from './components/FAB';
-export { default as AnimatedFAB } from './components/FAB/AnimatedFAB';
+export { default as FloatingActionButton } from './components/FAB/FloatingActionButton';
+export { default as ExtendedFloatingActionButton } from './components/FAB/ExtendedFloatingActionButton';
+export { default as FloatingActionButtonMenu } from './components/FAB/FloatingActionButtonMenu';
export { default as HelperText } from './components/HelperText/HelperText';
export { default as Icon } from './components/Icon';
export { default as IconButton } from './components/IconButton/IconButton';
@@ -59,7 +58,6 @@ export { default as Text, customText } from './components/Typography/Text';
// Types
export type { Props as ActivityIndicatorProps } from './components/ActivityIndicator';
-export type { Props as AnimatedFABProps } from './components/FAB/AnimatedFAB';
export type { Props as AppbarProps } from './components/Appbar/Appbar';
export type { Props as AppbarActionProps } from './components/Appbar/AppbarAction';
export type { Props as AppbarBackActionProps } from './components/Appbar/AppbarBackAction';
@@ -99,8 +97,16 @@ export type { Props as DividerProps } from './components/Divider';
export type { Props as DrawerCollapsedItemProps } from './components/Drawer/DrawerCollapsedItem';
export type { Props as DrawerItemProps } from './components/Drawer/DrawerItem';
export type { Props as DrawerSectionProps } from './components/Drawer/DrawerSection';
-export type { Props as FABProps } from './components/FAB/FAB';
-export type { Props as FABGroupProps } from './components/FAB/FABGroup';
+export type { Props as FloatingActionButtonProps } from './components/FAB/FloatingActionButton';
+export type { Props as ExtendedFloatingActionButtonProps } from './components/FAB/ExtendedFloatingActionButton';
+export type {
+ FloatingActionButtonMenuProps,
+ FloatingActionButtonMenuItemProps,
+} from './components/FAB/FloatingActionButtonMenu';
+export type {
+ FloatingActionButtonVariant,
+ FloatingActionButtonSize,
+} from './components/FAB/tokens';
export type { Props as HelperTextProps } from './components/HelperText/HelperText';
export type { Props as IconButtonProps } from './components/IconButton/IconButton';
export type { Props as ListAccordionProps } from './components/List/ListAccordion';
diff --git a/src/theme/utils/shape.ts b/src/theme/utils/shape.ts
new file mode 100644
index 0000000000..2ce6b2feb6
--- /dev/null
+++ b/src/theme/utils/shape.ts
@@ -0,0 +1,22 @@
+import { cornerFull, cornerNone } from '../tokens/sys/shape';
+import type { Theme, ThemeShapeCorners } from '../types';
+
+/**
+ * Token name accepted anywhere a corner radius is specified. `'none'` and
+ * `'full'` resolve to the `cornerNone` / `cornerFull` constants; named
+ * tokens key into `theme.shapes.corner`.
+ */
+export type ShapeToken = keyof ThemeShapeCorners | 'none' | 'full';
+
+/**
+ * Resolve a `ShapeToken` to a numeric corner radius.
+ */
+export function resolveCornerRadius(theme: Theme, token: ShapeToken): number {
+ if (token === 'full') {
+ return cornerFull;
+ }
+ if (token === 'none') {
+ return cornerNone;
+ }
+ return theme.shapes.corner[token];
+}
diff --git a/tsconfig.json b/tsconfig.json
index cc590cc0ee..e2ff899f74 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -31,7 +31,7 @@
"skipLibCheck": true,
"strict": true,
"target": "esnext",
- "types": ["jest", "node"]
+ "types": ["jest", "node", "@testing-library/jest-native"]
},
"exclude": [
"lib/**/*"