diff --git a/example/src/Examples/SurfaceExample.tsx b/example/src/Examples/SurfaceExample.tsx index eb2ac6a317..8fdc698141 100644 --- a/example/src/Examples/SurfaceExample.tsx +++ b/example/src/Examples/SurfaceExample.tsx @@ -1,53 +1,74 @@ import * as React from 'react'; -import { StyleSheet, View } from 'react-native'; +import { ScrollView, StyleSheet, View } from 'react-native'; -import { MD3Elevation, Surface, Text, MD3Colors } from 'react-native-paper'; +import { + MD3Elevation, + Surface, + Text, + MD3Colors, + List, +} from 'react-native-paper'; import { useExampleTheme } from '..'; -import { isWeb } from '../../utils'; import ScreenWrapper from '../ScreenWrapper'; const SurfaceExample = () => { const { isV3 } = useExampleTheme(); - const v2Elevation = [1, 2, 4, 8, 12]; - const baseElevation = isV3 ? Array.from({ length: 6 }) : v2Elevation; + const elevationValues = isV3 ? Array.from({ length: 6 }) : v2Elevation; - return ( - ( + - {baseElevation.map((e, i) => ( - - - {isV3 ? `Elevation ${i === 1 ? '(default)' : ''} ${i}` : `${e}`} - - - ))} + + {isV3 + ? `Elevation ${index === 1 ? '(default)' : ''} ${index}` + : `${elevationValues[index]}`} + + + ); + + return ( + + + + {elevationValues.map((_, index) => renderSurface(index, 'elevated'))} + + + + + + {elevationValues.map((_, index) => renderSurface(index, 'flat'))} + + - - - Left - - - Right - - - - - Top - - - Bottom - - + + + + + Left + + + Right + + + + + Top + + + Bottom + + + + ); }; @@ -59,11 +80,6 @@ const styles = StyleSheet.create({ padding: 24, alignItems: 'center', }, - webContent: { - flexDirection: 'row', - flexWrap: 'wrap', - padding: 0, - }, surface: { margin: 24, height: 80, diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx index 40ef36a3bf..98bb3efdbe 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -4,7 +4,7 @@ import { Animated, StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; import useLatestCallback from 'use-latest-callback'; import { useInternalTheme } from '../core/theming'; -import type { $RemoveChildren, ThemeProp } from '../types'; +import type { $Omit, $RemoveChildren, ThemeProp } from '../types'; import Button from './Button/Button'; import Icon, { IconSource } from './Icon'; import Surface from './Surface'; @@ -12,7 +12,7 @@ import Text from './Typography/Text'; const DEFAULT_MAX_WIDTH = 960; -export type Props = $RemoveChildren & { +export type Props = $Omit<$RemoveChildren, 'mode'> & { /** * Whether banner is currently visible. */ diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 36ff1d5c53..94ff5d774b 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -12,7 +12,7 @@ import { import color from 'color'; import { useInternalTheme } from '../../core/theming'; -import type { ThemeProp } from '../../types'; +import type { $Omit, ThemeProp } from '../../types'; import ActivityIndicator from '../ActivityIndicator'; import Icon, { IconSource } from '../Icon'; import Surface from '../Surface'; @@ -20,7 +20,7 @@ import TouchableRipple from '../TouchableRipple/TouchableRipple'; import Text from '../Typography/Text'; import { ButtonMode, getButtonColors } from './utils'; -export type Props = React.ComponentProps & { +export type Props = $Omit, 'mode'> & { /** * Mode of the button. You can change the mode to adjust the styling to give it desired emphasis. * - `text` - flat button without background or outline, used for the lowest priority actions, especially when presenting multiple options. diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index 63c6e0eb15..dd57825696 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -12,7 +12,7 @@ import { import useLatestCallback from 'use-latest-callback'; import { useInternalTheme } from '../../core/theming'; -import type { ThemeProp } from '../../types'; +import type { $Omit, ThemeProp } from '../../types'; import Surface from '../Surface'; import CardActions from './CardActions'; import CardContent from './CardContent'; @@ -41,7 +41,7 @@ type HandlePressType = 'in' | 'out'; type Mode = 'elevated' | 'outlined' | 'contained'; -export type Props = React.ComponentProps & { +export type Props = $Omit, 'mode'> & { /** * Mode of the Card. * - `elevated` - Card with elevation. diff --git a/src/components/Chip/Chip.tsx b/src/components/Chip/Chip.tsx index fda237a968..6da7972ff7 100644 --- a/src/components/Chip/Chip.tsx +++ b/src/components/Chip/Chip.tsx @@ -14,7 +14,7 @@ import { import { useInternalTheme } from '../../core/theming'; import { white } from '../../styles/themes/v2/colors'; -import type { EllipsizeProp, ThemeProp } from '../../types'; +import type { $Omit, EllipsizeProp, ThemeProp } from '../../types'; import type { IconSource } from '../Icon'; import Icon from '../Icon'; import MaterialCommunityIcon from '../MaterialCommunityIcon'; @@ -23,7 +23,7 @@ import TouchableRipple from '../TouchableRipple/TouchableRipple'; import Text from '../Typography/Text'; import { getChipColors } from './helpers'; -export type Props = React.ComponentProps & { +export type Props = $Omit, 'mode'> & { /** * Mode of the chip. * - `flat` - flat chip without outline. diff --git a/src/components/FAB/AnimatedFAB.tsx b/src/components/FAB/AnimatedFAB.tsx index cc4ec0b1bb..84ea8c6321 100644 --- a/src/components/FAB/AnimatedFAB.tsx +++ b/src/components/FAB/AnimatedFAB.tsx @@ -21,7 +21,7 @@ import { import color from 'color'; import { useInternalTheme } from '../../core/theming'; -import type { $RemoveChildren, ThemeProp } from '../../types'; +import type { $Omit, $RemoveChildren, ThemeProp } from '../../types'; import type { IconSource } from '../Icon'; import Icon from '../Icon'; import Surface from '../Surface'; @@ -32,7 +32,7 @@ import { getCombinedStyles, getFABColors } from './utils'; export type AnimatedFABIconMode = 'static' | 'dynamic'; export type AnimatedFABAnimateFrom = 'left' | 'right'; -export type Props = $RemoveChildren & { +export type Props = $Omit<$RemoveChildren, 'mode'> & { /** * Icon to display for the `FAB`. */ diff --git a/src/components/FAB/FAB.tsx b/src/components/FAB/FAB.tsx index 9d3f49bc41..c97c163124 100644 --- a/src/components/FAB/FAB.tsx +++ b/src/components/FAB/FAB.tsx @@ -10,7 +10,7 @@ import { } from 'react-native'; import { useInternalTheme } from '../../core/theming'; -import type { $RemoveChildren, ThemeProp } from '../../types'; +import type { $Omit, $RemoveChildren, ThemeProp } from '../../types'; import { forwardRef } from '../../utils/forwardRef'; import ActivityIndicator from '../ActivityIndicator'; import CrossFadeIcon from '../CrossFadeIcon'; @@ -34,7 +34,7 @@ type IconOrLabel = label: string; }; -export type Props = $RemoveChildren & { +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. /** diff --git a/src/components/Snackbar.tsx b/src/components/Snackbar.tsx index db3c4832b5..137d617867 100644 --- a/src/components/Snackbar.tsx +++ b/src/components/Snackbar.tsx @@ -13,7 +13,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import useLatestCallback from 'use-latest-callback'; import { useInternalTheme } from '../core/theming'; -import type { $RemoveChildren, ThemeProp } from '../types'; +import type { $Omit, $RemoveChildren, ThemeProp } from '../types'; import Button from './Button/Button'; import type { IconSource } from './Icon'; import IconButton from './IconButton/IconButton'; @@ -21,7 +21,7 @@ import MaterialCommunityIcon from './MaterialCommunityIcon'; import Surface from './Surface'; import Text from './Typography/Text'; -export type Props = React.ComponentProps & { +export type Props = $Omit, 'mode'> & { /** * Whether the Snackbar is currently visible. */ diff --git a/src/components/Surface.tsx b/src/components/Surface.tsx index 3503d9b916..ce8359d0f8 100644 --- a/src/components/Surface.tsx +++ b/src/components/Surface.tsx @@ -29,10 +29,19 @@ export type Props = React.ComponentPropsWithRef & { * Changes shadows and background on iOS and Android. * Used to create UI hierarchy between components. * + * Note: If `mode` is set to `flat`, Surface doesn't have a shadow. + * * Note: In version 2 the `elevation` prop was accepted via `style` prop i.e. `style={{ elevation: 4 }}`. * It's no longer supported with theme version 3 and you should use `elevation` property instead. */ elevation?: Elevation; + /** + * @supported Available in v5.x with theme version 3 + * Mode of the Surface. + * - `elevated` - Surface with a shadow and background color corresponding to set `elevation` value. + * - `flat` - Surface without a shadow, with the background color corresponding to set `elevation` value. + */ + mode?: 'flat' | 'elevated'; /** * @optional */ @@ -125,87 +134,102 @@ const SurfaceIOS = forwardRef< elevation: Elevation; backgroundColor?: string | Animated.AnimatedInterpolation; } ->(({ elevation, style, backgroundColor, testID, children, ...props }, ref) => { - const [outerLayerViewStyles, innerLayerViewStyles] = React.useMemo(() => { - const { - position, - alignSelf, - top, - left, - right, - bottom, - start, - end, - flex, - backgroundColor: backgroundColorStyle, - width, - height, - transform, - opacity, - ...restStyle - } = (StyleSheet.flatten(style) || {}) as ViewStyle; - - const [filteredStyles, marginStyles, borderRadiusStyles] = splitStyles( - restStyle, - (style) => style.startsWith('margin'), - (style) => style.startsWith('border') && style.endsWith('Radius') - ); +>( + ( + { + elevation, + style, + backgroundColor, + testID, + children, + mode = 'elevated', + ...props + }, + ref + ) => { + const [outerLayerViewStyles, innerLayerViewStyles] = React.useMemo(() => { + const { + position, + alignSelf, + top, + left, + right, + bottom, + start, + end, + flex, + backgroundColor: backgroundColorStyle, + width, + height, + transform, + opacity, + ...restStyle + } = (StyleSheet.flatten(style) || {}) as ViewStyle; - if ( - process.env.NODE_ENV !== 'production' && - filteredStyles.overflow === 'hidden' && - elevation !== 0 - ) { - console.warn( - 'When setting overflow to hidden on Surface the shadow will not be displayed correctly. Wrap the content of your component in a separate View with the overflow style.' + const [filteredStyles, marginStyles, borderRadiusStyles] = splitStyles( + restStyle, + (style) => style.startsWith('margin'), + (style) => style.startsWith('border') && style.endsWith('Radius') ); - } - const bgColor = backgroundColorStyle || backgroundColor; - - const outerLayerViewStyles = { - ...getStyleForShadowLayer(elevation, 0), - ...marginStyles, - ...borderRadiusStyles, - position, - alignSelf, - top, - right, - bottom, - left, - start, - end, - flex, - width, - height, - transform, - opacity, - backgroundColor: bgColor, - }; + if ( + process.env.NODE_ENV !== 'production' && + filteredStyles.overflow === 'hidden' && + elevation !== 0 + ) { + console.warn( + 'When setting overflow to hidden on Surface the shadow will not be displayed correctly. Wrap the content of your component in a separate View with the overflow style.' + ); + } - const innerLayerViewStyles = { - ...getStyleForShadowLayer(elevation, 1), - ...filteredStyles, - ...borderRadiusStyles, - flex: height ? 1 : undefined, - backgroundColor: bgColor, - }; + const bgColor = backgroundColorStyle || backgroundColor; - return [outerLayerViewStyles, innerLayerViewStyles]; - }, [style, elevation, backgroundColor]); + const isElevated = mode === 'elevated'; - return ( - - - {children} + const outerLayerViewStyles = { + ...(isElevated && getStyleForShadowLayer(elevation, 0)), + ...marginStyles, + ...borderRadiusStyles, + position, + alignSelf, + top, + right, + bottom, + left, + start, + end, + flex, + width, + height, + transform, + opacity, + backgroundColor: bgColor, + }; + + const innerLayerViewStyles = { + ...(isElevated && getStyleForShadowLayer(elevation, 1)), + ...filteredStyles, + ...borderRadiusStyles, + flex: height ? 1 : undefined, + backgroundColor: bgColor, + }; + + return [outerLayerViewStyles, innerLayerViewStyles]; + }, [style, elevation, backgroundColor, mode]); + + return ( + + + {children} + - - ); -}); + ); + } +); /** * Surface is a basic container that can give depth to an element with elevation shadow. @@ -257,6 +281,7 @@ const Surface = forwardRef( theme: overridenTheme, style, testID = 'surface', + mode = 'elevated', ...props }: Props, ref @@ -287,6 +312,8 @@ const Surface = forwardRef( return colors.elevation?.[`level${elevation}`]; })(); + const isElevated = mode === 'elevated'; + if (Platform.OS === 'web') { return ( ( testID={testID} style={[ { backgroundColor }, - elevation ? shadow(elevation, theme.isV3) : null, + elevation && isElevated ? shadow(elevation, theme.isV3) : null, style, ]} > @@ -337,7 +364,7 @@ const Surface = forwardRef( }, outerLayerStyles, sharedStyle, - { + isElevated && { elevation: getElevationAndroid(), }, ]} @@ -355,6 +382,7 @@ const Surface = forwardRef( backgroundColor={backgroundColor} style={style} testID={testID} + mode={mode} > {children} diff --git a/src/components/__tests__/Surface.test.tsx b/src/components/__tests__/Surface.test.tsx index d56fea2c82..8d8e639779 100644 --- a/src/components/__tests__/Surface.test.tsx +++ b/src/components/__tests__/Surface.test.tsx @@ -4,6 +4,7 @@ import { Platform } from 'react-native'; import { render } from '@testing-library/react-native'; +import { getTheme } from '../../core/theming'; import Surface from '../Surface'; describe('Surface', () => { @@ -39,6 +40,35 @@ describe('Surface', () => { }, }); + it('should render Surface with appropriate bg color but without shadow, if mode is set to "flat"', () => { + const { getByTestId } = render( + + {null} + + ); + + expect(getByTestId('surface-test')).not.toHaveStyle({ + shadowColor: '#000', + shadowOpacity: 0.3, + shadowOffset: { width: 0, height: 4 }, + shadowRadius: 4, + }); + expect(getByTestId('surface-test-outer-layer')).not.toHaveStyle({ + shadowColor: '#000', + shadowOpacity: 0.15, + shadowOffset: { width: 0, height: 8 }, + shadowRadius: 12, + }); + expect(getByTestId('surface-test')).toHaveStyle({ + backgroundColor: getTheme().colors.elevation.level5, + }); + }); + it.each` property | value ${'opacity'} | ${0.7} @@ -208,4 +238,26 @@ describe('Surface', () => { }); }); }); + + describe('on Android', () => { + it('should render Surface with appropriate bg color but without shadow, if mode is set to "flat"', () => { + Platform.OS = 'android'; + const testID = 'surface-container'; + const { getByTestId } = render( + + {null} + + ); + + expect(getByTestId(testID)).not.toHaveStyle({ elevation: 5 }); + expect(getByTestId(testID)).toHaveStyle({ + backgroundColor: getTheme().colors.elevation.level5, + }); + }); + }); });