diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 72aee6d00f..8355386412 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -114,6 +114,14 @@ The `style` props for `Appbar` and `Appbar.Header` no longer accept `Animated.Va The `style.elevation` property is no longer supported. Use the `elevated` prop to control Appbar elevation. +### Banner + +The banner background is now always `theme.colors.surfaceContainerLow` and no longer depends on the `elevation` prop, which only controls the shadow. If you customized `theme.colors.elevation.level1` to change the banner background, customize `theme.colors.surfaceContainerLow` instead. With `LightTheme` and `DarkTheme`, the rendered color at the default `elevation` of `1` is unchanged. With `DynamicLightTheme` and `DynamicDarkTheme` on Android 12 and newer, the banner now uses the system surface color. + +### Menu + +The menu background is now always `theme.colors.surfaceContainer`, following the Material Design 3 spec, and no longer depends on the `elevation` prop, which only controls the shadow. If you customized `theme.colors.elevation.level2` to change the menu background, customize `theme.colors.surfaceContainer` instead. With `LightTheme` and `DarkTheme`, the rendered color at the default `elevation` of `2` is unchanged. With `DynamicLightTheme` and `DynamicDarkTheme` on Android 12 and newer, the menu now uses the system surface color. + ### Surface - The `elevation` prop no longer accepts a React Native `Animated.Value`. Any `elevation` changes are animated automatically. diff --git a/docs/6.x/docs/guides/theming.mdx b/docs/6.x/docs/guides/theming.mdx index c7eed1703d..8b9c60cc22 100644 --- a/docs/6.x/docs/guides/theming.mdx +++ b/docs/6.x/docs/guides/theming.mdx @@ -106,7 +106,7 @@ A theme usually contains the following properties: - `errorContainer` - `onErrorContainer` - > Surfaces at elevation levels 0-5 are tinted via color overlays based on the primary color, such as app bars or menus. The addition of a grade from 0-5 introduces tonal variation to the surface baseline. + > Surfaces at elevation levels 0-5 are tinted via color overlays based on the primary color, such as app bars. Menus and banners use the `surfaceContainer` and `surfaceContainerLow` roles instead. The addition of a grade from 0-5 introduces tonal variation to the surface baseline. - `elevation` (`object`) - `level0` - transparent - `level1` - 5% opacity diff --git a/docs/src/data/themeColors.ts b/docs/src/data/themeColors.ts index 118114da27..2964440296 100644 --- a/docs/src/data/themeColors.ts +++ b/docs/src/data/themeColors.ts @@ -35,6 +35,7 @@ export const themeColors = { }, Banner: { '-': { + backgroundColor: 'theme.colors.surfaceContainerLow', textColor: 'theme.colors.onSurface', 'action textColor': 'theme.colors.primary', }, @@ -246,7 +247,7 @@ export const themeColors = { }, Menu: { '-': { - backgroundColor: 'theme.colors.elevation.level2', + backgroundColor: 'theme.colors.surfaceContainer', }, }, 'Menu.Item': { @@ -314,6 +315,9 @@ export const themeColors = { elevated: { backgroundColor: 'theme.colors.elevation[elevation]', }, + 'with container': { + backgroundColor: 'theme.colors[container]', + }, }, Text: { '-': { diff --git a/example/src/Examples/BannerExample.tsx b/example/src/Examples/BannerExample.tsx index 2a99b38866..ed226cf706 100644 --- a/example/src/Examples/BannerExample.tsx +++ b/example/src/Examples/BannerExample.tsx @@ -19,10 +19,7 @@ const BannerExample = () => { colors: { ...defaultTheme.colors, onSurface: Palette.tertiary100, - elevation: { - ...defaultTheme.colors.elevation, - level1: Palette.tertiary50, - }, + surfaceContainerLow: Palette.tertiary50, primary: Palette.tertiary10, }, }; diff --git a/example/src/Examples/SurfaceExample.tsx b/example/src/Examples/SurfaceExample.tsx index e941c7c843..26f3aad580 100644 --- a/example/src/Examples/SurfaceExample.tsx +++ b/example/src/Examples/SurfaceExample.tsx @@ -1,13 +1,33 @@ import * as React from 'react'; import { ScrollView, StyleSheet, View } from 'react-native'; -import { Surface, Text, Palette, List, IconButton } from 'react-native-paper'; -import type { Elevation } from 'react-native-paper'; +import { + Surface, + Text, + Palette, + List, + IconButton, + useTheme, +} from 'react-native-paper'; +import type { Elevation, SurfaceRole } from 'react-native-paper'; import ScreenWrapper from '../ScreenWrapper'; const elevationLevels: Elevation[] = [0, 1, 2, 3, 4, 5]; +const containerRoles: SurfaceRole[] = [ + 'surface', + 'surfaceDim', + 'surfaceBright', + 'surfaceVariant', + 'surfaceContainerLowest', + 'surfaceContainerLow', + 'surfaceContainer', + 'surfaceContainerHigh', + 'surfaceContainerHighest', + 'inverseSurface', +]; + const AnimatedSurface = () => { const [index, setIndex] = React.useState(3); @@ -37,8 +57,20 @@ const AnimatedSurface = () => { }; const SurfaceExample = () => { + const theme = useTheme(); + const elevationValues: Elevation[] = [0, 1, 2, 3, 4, 5]; + const onColorFor = (role: SurfaceRole) => { + if (role === 'inverseSurface') { + return theme.colors.inverseOnSurface; + } + if (role === 'surfaceVariant') { + return theme.colors.onSurfaceVariant; + } + return theme.colors.onSurface; + }; + const renderSurface = (index: Elevation, mode: 'flat' | 'elevated') => ( { + + + {containerRoles.map((role) => ( + + + {role} + + + ))} + + + @@ -125,6 +186,10 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, + roleSurface: { + width: 180, + borderWidth: StyleSheet.hairlineWidth, + }, actions: { flexDirection: 'row', gap: 16, diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx index 909f75bf3f..000ed067a7 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -66,7 +66,8 @@ export type Props = Omit & { contentStyle?: StyleProp; /** * @supported Available in v5.x with theme version 3 - * Changes Banner shadow and background on iOS and Android. + * Changes Banner shadow on iOS and Android. The banner background is + * `theme.colors.surfaceContainerLow` and is not affected by `elevation`. */ elevation?: Elevation; /** @@ -211,6 +212,7 @@ const Banner = ({ {...rest} style={[surfaceStyle, style]} theme={theme} + container="surfaceContainerLow" elevation={elevation} > diff --git a/src/components/Menu/Menu.tsx b/src/components/Menu/Menu.tsx index 6044956c06..dd9f51a2bc 100644 --- a/src/components/Menu/Menu.tsx +++ b/src/components/Menu/Menu.tsx @@ -82,14 +82,14 @@ export type Props = { contentStyle?: StyleProp; style?: StyleProp; /** - * Elevation level of the menu's content. Shadow styles are calculated based on this value. Default `backgroundColor` is taken from the corresponding `theme.colors.elevation` property. By default equals `2`. + * Elevation level of the menu's content. Shadow styles are calculated based on this value. The menu background is `theme.colors.surfaceContainer` per the MD3 spec and is not affected by `elevation`. By default equals `2`. * @supported Available in v5.x with theme version 3 */ elevation?: Elevation; /** * Mode of the menu's content. - * - `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. + * - `elevated` - Surface with a shadow corresponding to set `elevation` value. + * - `flat` - Surface without a shadow. * * @supported Available in v5.x with theme version 3 */ @@ -704,6 +704,7 @@ const Menu = ({ > = Extract< AnimatedStyle>>, @@ -22,8 +22,8 @@ type BorderRadius = AnimatedStyleProp<'borderRadius'>; type SurfaceVisualProps = { /** - * Background color of the Surface. Overrides the color derived from - * `elevation`. + * Background color of the Surface. Overrides both `container` and the + * color derived from `elevation`. */ backgroundColor?: ColorValue; /** @@ -104,6 +104,21 @@ export type Props = Omit & * To achieve the same effect, wrap the content in a child View with the overflow style. */ style?: StyleProp; + /** + * Semantic color role of the Surface background, resolved from the + * theme, e.g. `container="surfaceContainerLow"` renders with + * `theme.colors.surfaceContainerLow`. Accepted roles: `surface`, + * `surfaceDim`, `surfaceBright`, `surfaceVariant`, + * `surfaceContainerLowest`, `surfaceContainerLow`, `surfaceContainer`, + * `surfaceContainerHigh`, `surfaceContainerHighest` and `inverseSurface`. + * + * When `container` is set, `elevation` only controls the shadow (and + * z-order on Android) and no longer affects the background color. + * + * Precedence: `backgroundColor` > `container` > + * `theme.colors.elevation[levelN]` derived from `elevation`. + */ + container?: SurfaceRole; /** * @supported Available in v5.x with theme version 3 * Changes shadows and background on iOS and Android. @@ -111,6 +126,9 @@ export type Props = Omit & * * Note: If `mode` is set to `flat`, Surface doesn't have a shadow. * + * Note: If `backgroundColor` or `container` is set, `elevation` only + * affects the 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. */ @@ -118,8 +136,8 @@ export type Props = Omit & /** * @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. + * - `elevated` - Surface with a shadow and background color corresponding to set `elevation` value (unless `backgroundColor` or `container` is set). + * - `flat` - Surface without a shadow, with the background color corresponding to set `elevation` value (unless `backgroundColor` or `container` is set). */ mode?: 'flat' | 'elevated'; /** @@ -150,9 +168,19 @@ export type Props = Omit & * import { StyleSheet } from 'react-native'; * * const MyComponent = () => ( - * - * Surface - * + * <> + * + * Surface + * + * + * Semantic container color + * + * * ); * * export default MyComponent; @@ -170,6 +198,7 @@ export type Props = Omit & */ const Surface = ({ elevation = 1, + container, children, theme: overriddenTheme, style, @@ -199,7 +228,8 @@ const Surface = ({ const { colors } = theme; const backgroundColor = - customBackgroundColor ?? colors.elevation?.[`level${elevation}`]; + customBackgroundColor ?? + (container ? colors[container] : colors.elevation?.[`level${elevation}`]); const backgroundStyle = { backgroundColor }; diff --git a/src/components/__tests__/Banner.test.tsx b/src/components/__tests__/Banner.test.tsx index 5cbd07b387..2665e30001 100644 --- a/src/components/__tests__/Banner.test.tsx +++ b/src/components/__tests__/Banner.test.tsx @@ -11,7 +11,8 @@ import { } from '@jest/globals'; import { act } from '@testing-library/react-native'; -import { render } from '../../test-utils'; +import { render, screen } from '../../test-utils'; +import { LightTheme } from '../../theme/schemes'; import Banner from '../Banner'; it('renders hidden banner, without action buttons and without image', async () => { @@ -104,6 +105,18 @@ it('renders visible banner, with action buttons and with image', async () => { expect(tree).toMatchSnapshot(); }); +it('renders banner with surfaceContainerLow background', async () => { + await render( + + Text + + ); + + expect(screen.getByTestId('banner')).toHaveStyle({ + backgroundColor: LightTheme.colors.surfaceContainerLow, + }); +}); + it('render visible banner, with custom theme', async () => { const tree = ( await render( diff --git a/src/components/__tests__/Menu.test.tsx b/src/components/__tests__/Menu.test.tsx index c35880b48f..6c491bbe16 100644 --- a/src/components/__tests__/Menu.test.tsx +++ b/src/components/__tests__/Menu.test.tsx @@ -51,7 +51,7 @@ it('renders not visible menu', async () => { const elevations: Elevation[] = [0, 1, 2, 3, 4, 5]; elevations.forEach((elevation) => - it(`renders menu with background color based on elevation value = ${elevation}`, async () => { + it(`renders menu with surfaceContainer background regardless of elevation value = ${elevation}`, async () => { const testID = 'menu-with-elevation'; await render( @@ -71,7 +71,7 @@ elevations.forEach((elevation) => ); expect(screen.getByTestId(testID)).toHaveStyle({ - backgroundColor: LightTheme.colors.elevation[`level${elevation}`], + backgroundColor: LightTheme.colors.surfaceContainer, }); }) ); diff --git a/src/components/__tests__/Surface.test.tsx b/src/components/__tests__/Surface.test.tsx index 96ed707ae1..764cd06f6a 100644 --- a/src/components/__tests__/Surface.test.tsx +++ b/src/components/__tests__/Surface.test.tsx @@ -173,6 +173,142 @@ describe('Surface', () => { transitionProperty: expect.not.arrayContaining(['backgroundColor']), }); }); + + it('resolves the container role from the theme', async () => { + await render( + + {null} + + ); + + expect(screen.getByTestId('surface-test')).toHaveStyle({ + backgroundColor: LightTheme.colors.surfaceContainerHigh, + }); + }); + + it('prefers backgroundColor over container', async () => { + const backgroundColor = 'rgba(1, 2, 3, 0.5)'; + await render( + + {null} + + ); + + expect(screen.getByTestId('surface-test')).toHaveStyle({ + backgroundColor, + }); + }); + + it('prefers container over the elevation-derived color', async () => { + await render( + + {null} + + ); + + expect(screen.getByTestId('surface-test')).toHaveStyle({ + backgroundColor: LightTheme.colors.surfaceContainerLowest, + }); + }); + + it('keeps the elevation-driven shadow when container is set', async () => { + await render( + + {null} + + ); + + expect(screen.getByTestId('surface-test')).toHaveStyle({ + shadowOpacity: SPOT_SHADOW_OPACITY, + }); + }); + + it('does not render a shadow in flat mode when container is set', async () => { + await render( + + {null} + + ); + + // @ts-expect-error + expect(screen.getByTestId('surface-test')).not.toHaveStyle({ + shadowOpacity: expect.any(Number), + }); + }); + + it('keeps the container background when elevation changes', async () => { + await render( + + {null} + + ); + + await screen.rerender( + + {null} + + ); + await jest.runAllTimersAsync(); + + expect(screen.getByTestId('surface-test')).toHaveStyle({ + backgroundColor: LightTheme.colors.surfaceContainer, + }); + }); + + it('does not transition a DynamicColorIOS container role', async () => { + await render( + + {null} + + ); + + expect( + getAnimatedStyle(screen.getByTestId('surface-test')) + ).toMatchObject({ + transitionProperty: expect.not.arrayContaining(['backgroundColor']), + }); + }); }); describe('on Android', () => { @@ -208,6 +344,38 @@ describe('Surface', () => { }); }); + it('resolves the container role from the theme', async () => { + await render( + + {null} + + ); + + expect(screen.getByTestId('surface-container')).toHaveStyle({ + backgroundColor: LightTheme.colors.surfaceContainerHigh, + }); + }); + + it('keeps the elevation-driven dp value when container is set', async () => { + await render( + + {null} + + ); + + expect(screen.getByTestId('surface-container')).toHaveStyle({ + elevation: 12, + }); + }); + it('does not transition a PlatformColor background', async () => { await render( ; + export type ThemeColors = { primary: ColorValue; primaryContainer: ColorValue;