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/**/*"