Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/6.x/docs/guides/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/6.x/docs/guides/theming.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion docs/src/data/themeColors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const themeColors = {
},
Banner: {
'-': {
backgroundColor: 'theme.colors.surfaceContainerLow',
textColor: 'theme.colors.onSurface',
'action textColor': 'theme.colors.primary',
},
Expand Down Expand Up @@ -246,7 +247,7 @@ export const themeColors = {
},
Menu: {
'-': {
backgroundColor: 'theme.colors.elevation.level2',
backgroundColor: 'theme.colors.surfaceContainer',
},
},
'Menu.Item': {
Expand Down Expand Up @@ -314,6 +315,9 @@ export const themeColors = {
elevated: {
backgroundColor: 'theme.colors.elevation[elevation]',
},
'with container': {
backgroundColor: 'theme.colors[container]',
},
},
Text: {
'-': {
Expand Down
5 changes: 1 addition & 4 deletions example/src/Examples/BannerExample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
Expand Down
69 changes: 67 additions & 2 deletions example/src/Examples/SurfaceExample.tsx
Original file line number Diff line number Diff line change
@@ -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);

Expand Down Expand Up @@ -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') => (
<Surface
key={index}
Expand Down Expand Up @@ -75,6 +107,35 @@ const SurfaceExample = () => {
</ScrollView>
</List.Section>

<List.Section title="Semantic container colors">
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.scroll}
>
{containerRoles.map((role) => (
<Surface
key={role}
style={[
styles.surface,
styles.roleSurface,
{ borderColor: theme.colors.outlineVariant },
]}
borderRadius={8}
mode="flat"
container={role}
>
Comment on lines +117 to +127

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two problems with this row on iOS and Android:

  1. The surface and surfaceBright tiles have the same color as the screen, so they look like plain text with no tile.
  2. In the 120 pt tiles, surfaceContainerLowest and surfaceContainerLow wrap mid-word ("surfaceContainerLo / west").

iOS, tiles blend into the screen
iOS, labels wrap mid-word

An elevated tile makes every role visible, and it also shows that elevation only sets the shadow here. For the wrapping, use wider tiles for this row or a smaller text variant.

Suggested change
<Surface
key={role}
style={styles.surface}
borderRadius={8}
mode="flat"
container={role}
>
<Surface
key={role}
style={styles.surface}
borderRadius={8}
elevation={1}
container={role}
>

(via Claude, verify if it makes sense)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The elevated tile does not fix this in dark theme, because surface and surfaceDim are both neutral6 there and the shadow does not show on the dark screen (screenshots). A hairline outlineVariant border shows every tile in light and dark theme:

Suggested change
<Surface
key={role}
style={styles.surface}
borderRadius={8}
mode="flat"
container={role}
>
<Surface
key={role}
style={[
styles.surface,
{
borderWidth: StyleSheet.hairlineWidth,
borderColor: theme.colors.outlineVariant,
},
]}
borderRadius={8}
mode="flat"
container={role}
>

(via Claude, verify if it makes sense)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated

<Text
variant="bodySmall"
style={[styles.centerText, { color: onColorFor(role) }]}
>
{role}
</Text>
</Surface>
))}
</ScrollView>
</List.Section>

<List.Section title="Animated elevation">
<AnimatedSurface />
</List.Section>
Expand Down Expand Up @@ -125,6 +186,10 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
roleSurface: {
width: 180,
borderWidth: StyleSheet.hairlineWidth,
},
actions: {
flexDirection: 'row',
gap: 16,
Expand Down
4 changes: 3 additions & 1 deletion src/components/Banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ export type Props = Omit<ViewProps, 'style'> & {
contentStyle?: StyleProp<ViewStyle>;
/**
* @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;
/**
Expand Down Expand Up @@ -211,6 +212,7 @@ const Banner = ({
{...rest}
style={[surfaceStyle, style]}
theme={theme}
container="surfaceContainerLow"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This breaks the "Set custom theme" action in the Banner example on iOS, Android and web. example/src/Examples/BannerExample.tsx sets the custom background through colors.elevation.level1 and the text through onSurface: Palette.tertiary100. Banner no longer reads elevation.level1, so the near white text now sits on the light surfaceContainerLow background.

iOS, Banner example with custom theme

Update the example to override the role Banner now reads:

  const customTheme = {
    ...defaultTheme,
    colors: {
      ...defaultTheme.colors,
      onSurface: Palette.tertiary100,
      surfaceContainerLow: Palette.tertiary50,
      primary: Palette.tertiary10,
    },
  };

Any app that customized elevation.level1 to style Banner (or elevation.level2 for Menu) hits the same problem, so the migration guide should also say which role to override. (via Claude, verify if it makes sense)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated

elevation={elevation}
>
<View style={[styles.wrapper, contentStyle]}>
Expand Down
7 changes: 4 additions & 3 deletions src/components/Menu/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,14 @@ export type Props = {
contentStyle?: StyleProp<SurfaceStyle>;
style?: StyleProp<ViewStyle>;
/**
* 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
*/
Expand Down Expand Up @@ -704,6 +704,7 @@ const Menu = ({
>
<Surface
mode={mode}
container="surfaceContainer"
borderRadius={theme.shapes.corner.extraSmall}
style={[
styles.shadowMenuContainer,
Expand Down
2 changes: 1 addition & 1 deletion src/components/Searchbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ const Searchbar = ({

return (
<Surface
backgroundColor={theme.colors.surfaceContainerHigh}
container="surfaceContainerHigh"
borderRadius={isBarMode ? theme.shapes.corner.extraLarge : cornerNone}
style={[styles.container, style]}
elevation={elevation}
Expand Down
48 changes: 39 additions & 9 deletions src/components/Surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import Animated, {

import { useInternalTheme } from '../core/theming';
import { androidElevationLevels, shadow } from '../theme/tokens/sys/elevation';
import type { Elevation, ThemeProp } from '../theme/types';
import type { Elevation, SurfaceRole, ThemeProp } from '../theme/types';

type AnimatedStyleProp<Key extends keyof ViewStyle> = Extract<
AnimatedStyle<Required<Pick<ViewStyle, Key>>>,
Expand All @@ -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;
/**
Expand Down Expand Up @@ -104,22 +104,40 @@ export type Props = Omit<ViewProps, 'pointerEvents' | 'style'> &
* To achieve the same effect, wrap the content in a child View with the overflow style.
*/
style?: StyleProp<SurfaceStyle>;
/**
* 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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The existing backgroundColor prop already does this. backgroundColor={theme.colors.surfaceContainer} resolves from the theme, follows theme changes, and makes elevation control only the shadow, because it also wins over the elevation color. Searchbar (backgroundColor={theme.colors.surfaceContainerHigh}) and Dialog (surfaceContainerHigh) already use that pattern. With this PR, the library has two ways to set a surface role, and the new JSDoc tells contributors to prefer container, which those two components do not follow.

The name is also unclear. container="surface" or container="inverseSurface" does not name a container, and "container" in this library usually means a wrapper element (contentContainerStyle, styles.container).

Suggested fix: drop container and SurfaceRole, and pass backgroundColor={theme.colors.surfaceContainer} in Menu and backgroundColor={theme.colors.surfaceContainerLow} in Banner. The behavior stays the same and the public API does not grow. If you keep the prop, migrate Searchbar and Dialog to it so there is one pattern. (via Claude, verify if it makes sense)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept container so components can name an MD3 role instead of resolving a raw color, and moved Searchbar to it. I left Dialog as is, because it sets its color through Modal's public contentBackgroundColor and moving it would mean adding a new Modal prop.

/**
* @supported Available in v5.x with theme version 3
* 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: 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.
*/
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.
* - `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';
/**
Expand Down Expand Up @@ -150,9 +168,19 @@ export type Props = Omit<ViewProps, 'pointerEvents' | 'style'> &
* import { StyleSheet } from 'react-native';
*
* const MyComponent = () => (
* <Surface style={styles.surface} elevation={4} borderRadius={8}>
* <Text>Surface</Text>
* </Surface>
* <>
* <Surface style={styles.surface} elevation={4} borderRadius={8}>
* <Text>Surface</Text>
* </Surface>
* <Surface
* style={styles.surface}
* container="surfaceContainerLow"
* elevation={1}
* borderRadius={8}
* >
* <Text>Semantic container color</Text>
* </Surface>
* </>
* );
*
* export default MyComponent;
Expand All @@ -170,6 +198,7 @@ export type Props = Omit<ViewProps, 'pointerEvents' | 'style'> &
*/
const Surface = ({
elevation = 1,
container,
children,
theme: overriddenTheme,
style,
Expand Down Expand Up @@ -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 };

Expand Down
15 changes: 14 additions & 1 deletion src/components/__tests__/Banner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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(
<Banner visible testID="banner">
Text
</Banner>
);

expect(screen.getByTestId('banner')).toHaveStyle({
backgroundColor: LightTheme.colors.surfaceContainerLow,
});
});

it('render visible banner, with custom theme', async () => {
const tree = (
await render(
Expand Down
4 changes: 2 additions & 2 deletions src/components/__tests__/Menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -71,7 +71,7 @@ elevations.forEach((elevation) =>
);

expect(screen.getByTestId(testID)).toHaveStyle({
backgroundColor: LightTheme.colors.elevation[`level${elevation}`],
backgroundColor: LightTheme.colors.surfaceContainer,
});
})
);
Expand Down
Loading
Loading