diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index c4874a10a9..10e2d7e494 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -115,6 +115,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. At the default `elevation` of `1` the rendered color is unchanged. + +### 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. At the default `elevation` of `2` the rendered color is unchanged. + ### Surface - The `elevation` prop no longer accepts a React Native `Animated.Value`. Any `elevation` changes are animated automatically. @@ -138,6 +146,7 @@ The `style.elevation` property is no longer supported. Use the `elevated` prop t - The `pointerEvents` prop is no longer supported as it's deprecated in React Native Web. You can specify `pointerEvents` in the `style` prop instead. - The `overflow: 'hidden'` style is no longer supported in `style` as it can clip shadows. You can nest a `View` inside the `Surface` and apply `overflow: 'hidden'` to that instead. - The default `testID` for `Surface` was removed. You can specify a `testID` explicitly if you need it. +- A new `container` prop sets the background to a semantic surface-family color role from the theme (e.g. `container="surfaceContainerLow"`). When `container` is set, `elevation` only controls the shadow. Precedence: `backgroundColor` > `container` > the color derived from `elevation`. e.g.: 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/SurfaceExample.tsx b/example/src/Examples/SurfaceExample.tsx index e941c7c843..3036dae607 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} + + + ))} + + + 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 96dd4096b7..12afb057f6 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,24 @@ 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`. + * + * 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`. + * + * Prefer `container` when the fill is an MD3 surface-family role (as + * component specs define it, e.g. menus use `surfaceContainer`). Use + * `backgroundColor` only for raw, non-semantic colors. Components with + * variant-driven fills outside the surface family should keep resolving + * their own tokens and pass the result via `backgroundColor`. + */ + container?: SurfaceRole; /** * @supported Available in v5.x with theme version 3 * Changes shadows and background on iOS and Android. @@ -111,6 +129,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 +139,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 +171,19 @@ export type Props = Omit & * import { StyleSheet } from 'react-native'; * * const MyComponent = () => ( - * - * Surface - * + * <> + * + * Surface + * + * + * Semantic container color + * + * * ); * * export default MyComponent; @@ -170,6 +201,7 @@ export type Props = Omit & */ const Surface = ({ elevation = 1, + container, children, theme: overriddenTheme, style, @@ -199,7 +231,9 @@ const Surface = ({ const { colors } = theme; const backgroundColor = - customBackgroundColor ?? colors.elevation?.[`level${elevation}`]; + customBackgroundColor ?? + (container != null ? colors[container] : undefined) ?? + 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;