diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md
index a4d7123a09..d7f33de5f8 100644
--- a/docs/6.x/docs/guides/migration.md
+++ b/docs/6.x/docs/guides/migration.md
@@ -82,9 +82,54 @@ You can specify a `testID` explicitly and use that value to query the component.
### Appbar
-The `style` props for `Appbar` and `Appbar.Header` no longer accept `Animated.Value` or `Animated.AnimatedInterpolation`. They only accept static styles.
+The Paper 6.x `Appbar` is a major refactor that drops the compound component API.
-The `style.elevation` property is no longer supported. Use the `elevated` prop to control Appbar elevation.
+The `style` prop no longer accepts `Animated.Value` or `Animated.AnimatedInterpolation`. Use Reanimated as described above. The `style.elevation` property is no longer supported.
+
+#### Migrating from the compound API
+
+`Appbar.Header`, `Appbar.Content`, `Appbar.Action`, and `Appbar.BackAction` have been removed. Render one `Appbar` and provide its headline, leading button, and trailing actions as props. The former `mode="medium"` and `mode="large"` values are now `variant="medium-flexible"` and `variant="large-flexible"`; centered content uses `headlineAlignment="center"`.
+
+```tsx
+// Before (v5)
+
+const MyComponent = () => (
+
+ {}} />
+
+ {}} />
+ {}} />
+
+);
+
+// After (v6)
+
+const MyComponent = () => (
+ {} }}
+ trailingActions={[
+ {
+ key: 'search',
+ icon: 'magnify',
+ 'aria-label': 'Search',
+ onPress: () => {},
+ },
+ {
+ key: 'more',
+ icon: 'dots-vertical',
+ 'aria-label': 'More options',
+ onPress: () => {},
+ },
+ ]}
+ />
+);
+```
+
+#### Bottom toolbar support
+
+Material Design 3 moves bottom app bars out of the top app bar component. Use the `Toolbar` component to construct a bottom bar.
### Surface
diff --git a/docs/6.x/docs/guides/react-navigation.md b/docs/6.x/docs/guides/react-navigation.md
index 6c01200f11..2e655b2e87 100644
--- a/docs/6.x/docs/guides/react-navigation.md
+++ b/docs/6.x/docs/guides/react-navigation.md
@@ -136,11 +136,7 @@ Now we will implement `CustomNavigationBar` using `AppBar` component:
import { Appbar } from 'react-native-paper';
export default function CustomNavigationBar() {
- return (
-
-
-
- );
+ return ;
}
```
@@ -154,11 +150,7 @@ import { getHeaderTitle } from '@react-navigation/elements';
export default function CustomNavigationBar({ route, options }) {
const title = getHeaderTitle(options, route.name);
- return (
-
-
-
- );
+ return ;
}
```
@@ -179,10 +171,13 @@ export default function CustomNavigationBar({
const title = getHeaderTitle(options, route.name);
return (
-
- {back ? : null}
-
-
+
);
}
```
@@ -194,8 +189,8 @@ export default function CustomNavigationBar({
Another interesting pattern that can be implemented with `react-native-paper` and `react-navigation` is a "menu" button. Thanks to the `Menu` component we can add a nice looking pop-up to our `Appbar`. To implement this feature we need to make a couple of changes in `CustomNavigationBar`:
- Render a `Menu` component
-- Pass `Appbar.Action` to the anchor prop
-- Add a state to control `Menu` visibility
+- Add a trailing action to `Appbar`
+- Store the action coordinates and menu visibility in state
:::note
To have properly working `Menu` component, remember to wrap your root component with the `PaperProvider`:
@@ -236,21 +231,41 @@ export default function CustomNavigationBar({
back,
}) {
const [visible, setVisible] = React.useState(false);
+ const [menuAnchor, setMenuAnchor] = React.useState({ x: 0, y: 0 });
const openMenu = () => setVisible(true);
const closeMenu = () => setVisible(false);
const title = getHeaderTitle(options, route.name);
return (
-
- {back ? : null}
-
+ <>
+ {
+ setMenuAnchor({
+ x: event.nativeEvent.pageX,
+ y: event.nativeEvent.pageY,
+ });
+ openMenu();
+ },
+ },
+ ]
+ }
+ />
{!back ? (
- }
- >
+
) : null}
-
+ >
);
}
```
diff --git a/docs/6.x/docs/guides/theming-with-react-navigation.md b/docs/6.x/docs/guides/theming-with-react-navigation.md
index ada86a611c..33842d29fe 100644
--- a/docs/6.x/docs/guides/theming-with-react-navigation.md
+++ b/docs/6.x/docs/guides/theming-with-react-navigation.md
@@ -249,24 +249,25 @@ Now that the Context is available at every component, all we need to do is impor
```js
import React from 'react';
-import { useTheme, Appbar, TouchableRipple, Switch } from 'react-native-paper';
+import { Appbar } from 'react-native-paper';
import { PreferencesContext } from './PreferencesContext';
const Header = ({ scene }) => {
- const theme = useTheme();
const { toggleTheme, isThemeDark } = React.useContext(PreferencesContext);
return (
-
-
-
-
+ ]}
+ />
);
};
```
diff --git a/docs/6.x/docs/guides/theming.mdx b/docs/6.x/docs/guides/theming.mdx
index 58836dc167..30c8ac53bb 100644
--- a/docs/6.x/docs/guides/theming.mdx
+++ b/docs/6.x/docs/guides/theming.mdx
@@ -503,14 +503,14 @@ Now you can use your `FancyButton` component everywhere instead of using `Button
## Dark Theme
Since 3.0 we adapt dark theme to follow [Material design guidelines](https://material.io/design/color/dark-theme.html).
-In contrast to light theme, dark theme by default uses `surface` colour instead of `primary` on large components like `AppBar` or `BottomNavigation`.
+In contrast to light theme, dark theme by default uses `surface` colour instead of `primary` on large components like `BottomNavigation`.
The dark theme adds a white overlay with opacity depending on elevation of surfaces. It uses it for the better accentuation of surface elevation. Using only shadow is highly imperceptible on dark surfaces.
We are aware that users often use dark theme in their own ways and may not want to use the default dark theme features from the guidelines.
That's why if you are using dark theme you can switch between two dark theme `mode`s:
-- `exact` where everything is like it was before. `Appbar` and `BottomNavigation` will still use primary colour by default.
-- `adaptive` where we follow [Material design guidelines](https://material.io/design/color/dark-theme.html), the surface will use white overlay with opacity to show elevation, `Appbar` and `BottomNavigation` will use surface colour as a background.
+- `exact` where everything is like it was before. `BottomNavigation` will still use primary colour by default.
+- `adaptive` where we follow [Material design guidelines](https://material.io/design/color/dark-theme.html), the surface will use white overlay with opacity to show elevation, and `BottomNavigation` will use surface colour as a background.
If you don't use a custom theme, Paper will automatically change between the default theme and the default dark theme, depending on device settings.
diff --git a/docs/component-docs.config.ts b/docs/component-docs.config.ts
index 4196e8e7bd..f568ad93b9 100644
--- a/docs/component-docs.config.ts
+++ b/docs/component-docs.config.ts
@@ -13,6 +13,7 @@ export type Page =
source: string;
component?: string;
props?: string;
+ propsSource?: string;
title?: string;
};
@@ -33,11 +34,9 @@ type ComponentDocsConfig = {
const pages = {
ActivityIndicator: 'ActivityIndicator',
Appbar: {
- Appbar: 'Appbar/Appbar',
- AppbarAction: 'Appbar/AppbarAction',
- AppbarBackAction: 'Appbar/AppbarBackAction',
- AppbarContent: 'Appbar/AppbarContent',
- AppbarHeader: 'Appbar/AppbarHeader',
+ source: 'Appbar/Appbar',
+ component: 'Appbar',
+ propsSource: 'Appbar/types',
},
Avatar: {
AvatarIcon: 'Avatar/AvatarIcon',
diff --git a/docs/plugins/component-docs/parser.ts b/docs/plugins/component-docs/parser.ts
index 800bd8f2f3..cb798f7a33 100644
--- a/docs/plugins/component-docs/parser.ts
+++ b/docs/plugins/component-docs/parser.ts
@@ -461,11 +461,27 @@ export const createComponentParser = (tsconfigPath: string) => {
const componentName = page.component ?? getDefaultExportName(sourceFile);
const component = findComponentDeclaration(sourceFile, componentName);
+ const propsSourcePath = page.propsSource
+ ? path.join(sourceRootDir, `${page.propsSource}.ts`)
+ : sourcePath;
+ const propsSourceFile = page.propsSource
+ ? program.getSourceFile(propsSourcePath)
+ : sourceFile;
+
+ if (!propsSourceFile || !fs.existsSync(propsSourcePath)) {
+ return fail(
+ propsSourcePath,
+ 'props source file was not found in the TypeScript program'
+ );
+ }
+
const propsType = findPropsType(
- sourceFile,
+ propsSourceFile,
page.props ?? DEFAULT_PROPS_TYPE
);
- validatePropsType(sourceFile, propsType);
+ if (!page.propsSource) {
+ validatePropsType(propsSourceFile, propsType);
+ }
const defaults = getParameterDefaults(
checker,
sourceFile,
@@ -474,7 +490,7 @@ export const createComponentParser = (tsconfigPath: string) => {
const props = getProps(
checker,
sourceRootDir,
- sourceFile,
+ propsSourceFile,
propsType,
defaults
);
diff --git a/docs/plugins/component-docs/types.ts b/docs/plugins/component-docs/types.ts
index e48b70e8a6..0e21fb9660 100644
--- a/docs/plugins/component-docs/types.ts
+++ b/docs/plugins/component-docs/types.ts
@@ -2,6 +2,7 @@ export type ComponentPageConfig = {
source: string;
component?: string;
props?: string;
+ propsSource?: string;
title?: string;
};
diff --git a/docs/public/screenshots/appbar-v6-large-flexible.png b/docs/public/screenshots/appbar-v6-large-flexible.png
new file mode 100644
index 0000000000..65b7a8c111
Binary files /dev/null and b/docs/public/screenshots/appbar-v6-large-flexible.png differ
diff --git a/docs/public/screenshots/appbar-v6-medium-flexible.png b/docs/public/screenshots/appbar-v6-medium-flexible.png
new file mode 100644
index 0000000000..0a1c1db9e0
Binary files /dev/null and b/docs/public/screenshots/appbar-v6-medium-flexible.png differ
diff --git a/docs/public/screenshots/appbar-v6-search.png b/docs/public/screenshots/appbar-v6-search.png
new file mode 100644
index 0000000000..f8ae3e63f8
Binary files /dev/null and b/docs/public/screenshots/appbar-v6-search.png differ
diff --git a/docs/public/screenshots/appbar-v6-small.png b/docs/public/screenshots/appbar-v6-small.png
new file mode 100644
index 0000000000..bc47f6512d
Binary files /dev/null and b/docs/public/screenshots/appbar-v6-small.png differ
diff --git a/docs/src/data/screenshots.ts b/docs/src/data/screenshots.ts
index 92bf8f2788..efcec64c88 100644
--- a/docs/src/data/screenshots.ts
+++ b/docs/src/data/screenshots.ts
@@ -1,18 +1,14 @@
export const screenshots = {
ActivityIndicator: 'screenshots/activity-indicator.gif',
- Appbar: 'screenshots/appbar.png',
- 'Appbar.Action': 'screenshots/appbar-action-android.png',
- 'Appbar.BackAction': 'screenshots/appbar-backaction-android.png',
- 'Appbar.Content': 'screenshots/appbar-content.png',
- 'Appbar.Header': {
- small: 'screenshots/appbar-small.png',
- medium: 'screenshots/appbar-medium.png',
- large: 'screenshots/appbar-large.png',
- 'center-aligned': 'screenshots/appbar-center-aligned.png',
- },
'Avatar.Icon': 'screenshots/avatar-icon.png',
'Avatar.Image': 'screenshots/avatar-image.png',
'Avatar.Text': 'screenshots/avatar-text.png',
+ Appbar: {
+ search: 'screenshots/appbar-v6-search.png',
+ small: 'screenshots/appbar-v6-small.png',
+ 'medium flexible': 'screenshots/appbar-v6-medium-flexible.png',
+ 'large flexible': 'screenshots/appbar-v6-large-flexible.png',
+ },
Badge: {
'with text': 'screenshots/badge-1.png',
'without text': 'screenshots/badge-2.png',
diff --git a/docs/src/data/themeColors.ts b/docs/src/data/themeColors.ts
index 20f16962f7..d347b65889 100644
--- a/docs/src/data/themeColors.ts
+++ b/docs/src/data/themeColors.ts
@@ -8,29 +8,20 @@ export const themeColors = {
default: {
backgroundColor: 'theme.colors.surface',
},
- elevated: {
- backgroundColor: 'theme.colors.elevation.level2',
+ scrolled: {
+ backgroundColor: 'theme.colors.surfaceContainer',
},
- },
- 'Appbar.Action': {
- 'leading icon': {
+ 'leading action': {
iconColor: 'theme.colors.onSurface',
},
- 'not leading icon': {
+ 'trailing action': {
iconColor: 'theme.colors.onSurfaceVariant',
},
- },
- 'Appbar.Content': {
- '-': {
+ headline: {
textColor: 'theme.colors.onSurface',
},
- },
- 'Appbar.Header': {
- default: {
- backgroundColor: 'theme.colors.surface',
- },
- elevated: {
- backgroundColor: 'theme.colors.elevation.level2',
+ subtitle: {
+ textColor: 'theme.colors.onSurfaceVariant',
},
},
Banner: {
diff --git a/docs/src/data/versionRouteFallbacks.json b/docs/src/data/versionRouteFallbacks.json
index 326741b5b5..df2f5e3ac0 100644
--- a/docs/src/data/versionRouteFallbacks.json
+++ b/docs/src/data/versionRouteFallbacks.json
@@ -1,5 +1,10 @@
{
"next": {
+ "/docs/components/Appbar/Appbar": "/6.x/docs/components/Appbar",
+ "/docs/components/Appbar/AppbarAction": "/6.x/docs/components/Appbar",
+ "/docs/components/Appbar/AppbarBackAction": "/6.x/docs/components/Appbar",
+ "/docs/components/Appbar/AppbarContent": "/6.x/docs/components/Appbar",
+ "/docs/components/Appbar/AppbarHeader": "/6.x/docs/components/Appbar",
"/docs/components/Checkbox/CheckboxAndroid": "/6.x/docs/components/Checkbox/Checkbox",
"/docs/components/Checkbox/CheckboxIOS": "/6.x/docs/components/Checkbox/Checkbox",
"/docs/components/FAB/AnimatedFAB": "/6.x/docs/components/FAB/FAB",
@@ -9,6 +14,7 @@
"/docs/guides/migration-guide-to-5.0": "/6.x/docs/guides/migration"
},
"stable": {
+ "/6.x/docs/components/Appbar": "/docs/components/Appbar/Appbar",
"/6.x/docs/components/FAB/FABExtended": "/docs/components/FAB/FAB",
"/6.x/docs/components/FAB/FABMenu": "/docs/components/FAB/FAB",
"/6.x/docs/guides/migration": "/docs/guides/migration-guide-to-5.0",
diff --git a/example/src/Examples/AppbarExample.tsx b/example/src/Examples/AppbarExample.tsx
index 2d4eb0bbc4..17f20da4ee 100644
--- a/example/src/Examples/AppbarExample.tsx
+++ b/example/src/Examples/AppbarExample.tsx
@@ -1,118 +1,320 @@
import * as React from 'react';
-import { Platform, StyleSheet, View } from 'react-native';
+import {
+ Image,
+ Platform,
+ StyleSheet,
+ useWindowDimensions,
+ View,
+} from 'react-native';
+import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import {
Appbar,
- FAB,
List,
Palette,
RadioButton,
Snackbar,
Switch,
Text,
- useTheme,
+} from 'react-native-paper';
+import type {
+ AppbarFilledTrailingAction,
+ AppbarHeadlineAlignment,
+ AppbarLeadingButton,
+ AppbarStandardTrailingAction,
+ AppbarTrailingActions,
+ AppbarVariant,
} from 'react-native-paper';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import ScreenWrapper from '../ScreenWrapper';
-type AppbarModes = 'small' | 'medium' | 'large' | 'center-aligned';
+type FilledTrailingActionVariant = AppbarFilledTrailingAction['variant'];
+type FilledTrailingActionWidth = NonNullable<
+ AppbarFilledTrailingAction['width']
+>;
const MORE_ICON = Platform.OS === 'ios' ? 'dots-horizontal' : 'dots-vertical';
-const MEDIUM_FAB_HEIGHT = 56;
+
+type SearchAppbarHeaderProps = {
+ trailingActions: AppbarStandardTrailingAction[];
+ isScrolled: boolean;
+ leadingButton?: AppbarLeadingButton;
+ showCustomColor: boolean;
+};
+
+const SearchAppbarHeader = ({
+ trailingActions,
+ isScrolled,
+ leadingButton,
+ showCustomColor,
+}: SearchAppbarHeaderProps) => {
+ const [searchQuery, setSearchQuery] = React.useState('');
+
+ return (
+
+ );
+};
const AppbarExample = () => {
const navigation = useNavigation('Appbar');
- const [showLeftIcon, setShowLeftIcon] = React.useState(true);
+ const [showLeadingButton, setShowLeadingButton] = React.useState(true);
const [showSubtitle, setShowSubtitle] = React.useState(true);
+ const [showHeadlineImage, setShowHeadlineImage] = React.useState(false);
const [showSearchIcon, setShowSearchIcon] = React.useState(true);
const [showMoreIcon, setShowMoreIcon] = React.useState(true);
const [showCustomColor, setShowCustomColor] = React.useState(false);
- const [appbarMode, setAppbarMode] = React.useState('small');
+ const [appbarConfiguration, setAppbarConfiguration] =
+ React.useState('small');
+ const [isHeadlineCentered, setIsHeadlineCentered] = React.useState(false);
const [showCalendarIcon, setShowCalendarIcon] = React.useState(false);
- const [showElevated, setShowElevated] = React.useState(false);
+ const [showFilledTrailingAction, setShowFilledTrailingAction] =
+ React.useState(false);
+ const [filledTrailingActionVariant, setFilledTrailingActionVariant] =
+ React.useState('filled');
+ const [filledTrailingActionWidth, setFilledTrailingActionWidth] =
+ React.useState('default');
+ const [isScrolled, setIsScrolled] = React.useState(false);
const [showSnackbar, setShowSnackbar] = React.useState(false);
- const theme = useTheme();
- const { bottom, left, right } = useSafeAreaInsets();
- const height = 80;
-
- const isCenterAlignedMode = appbarMode === 'center-aligned';
+ const { bottom } = useSafeAreaInsets();
+ const { height: windowHeight } = useWindowDimensions();
+ const headlineAlignment: AppbarHeadlineAlignment = isHeadlineCentered
+ ? 'center'
+ : 'leading';
React.useLayoutEffect(() => {
+ const standardTrailingActions: AppbarStandardTrailingAction[] = [];
+
+ if (showCalendarIcon) {
+ standardTrailingActions.push({
+ key: 'calendar',
+ icon: 'calendar',
+ 'aria-label': 'Calendar',
+ onPress: () => {},
+ });
+ }
+
+ if (showSearchIcon && appbarConfiguration !== 'search') {
+ standardTrailingActions.push({
+ key: 'search',
+ icon: 'magnify',
+ 'aria-label': 'Search',
+ onPress: () => {},
+ });
+ }
+
+ if (showMoreIcon) {
+ standardTrailingActions.push({
+ key: 'more',
+ icon: MORE_ICON,
+ 'aria-label': 'More options',
+ onPress: () => {},
+ });
+ }
+
+ const trailingActions: AppbarTrailingActions = showFilledTrailingAction
+ ? [
+ {
+ key: 'share',
+ icon: 'share-variant',
+ 'aria-label': 'Share',
+ variant: filledTrailingActionVariant,
+ width: filledTrailingActionWidth,
+ onPress: () => {},
+ },
+ ]
+ : standardTrailingActions;
+
+ const leadingButton = showLeadingButton
+ ? {
+ type: 'back' as const,
+ onPress: () => navigation.goBack(),
+ }
+ : undefined;
+ const headlineImage = (
+
+ );
+ const commonProps = {
+ headlineAlignment,
+ isScrolled,
+ leadingButton,
+ onHeadlinePress: () => setShowSnackbar(true),
+ style: showCustomColor ? styles.customColor : null,
+ trailingActions,
+ };
+
navigation.setOptions({
- header: () => (
-
- {showLeftIcon && (
- navigation.goBack()} />
- )}
- setShowSnackbar(true)} />
- {isCenterAlignedMode
- ? false
- : showCalendarIcon && (
- {}} />
- )}
- {showSearchIcon && (
- {}} />
- )}
- {showMoreIcon && (
- {}} />
- )}
-
- ),
+ header: () => {
+ if (appbarConfiguration === 'search') {
+ return (
+
+ );
+ }
+
+ if (appbarConfiguration === 'small') {
+ return showHeadlineImage ? (
+
+ ) : (
+
+ );
+ }
+
+ return showHeadlineImage ? (
+
+ ) : (
+
+ );
+ },
});
}, [
+ appbarConfiguration,
+ filledTrailingActionVariant,
+ filledTrailingActionWidth,
navigation,
- showLeftIcon,
- showSubtitle,
- showSearchIcon,
- showMoreIcon,
- showCustomColor,
- appbarMode,
showCalendarIcon,
- isCenterAlignedMode,
- showElevated,
+ showCustomColor,
+ showFilledTrailingAction,
+ showLeadingButton,
+ showMoreIcon,
+ isScrolled,
+ showSearchIcon,
+ showSubtitle,
+ showHeadlineImage,
+ headlineAlignment,
]);
- const renderFAB = () => {
- return (
- {}}
- style={[styles.fab, { top: (height - MEDIUM_FAB_HEIGHT) / 2 }]}
- />
- );
- };
+ const handleScroll = React.useCallback(
+ ({ nativeEvent }: NativeSyntheticEvent) => {
+ const nextIsScrolled = nativeEvent.contentOffset.y > 1;
+
+ setIsScrolled((currentIsScrolled) =>
+ currentIsScrolled === nextIsScrolled
+ ? currentIsScrolled
+ : nextIsScrolled
+ );
+ },
+ []
+ );
const renderDefaultOptions = () => (
<>
- Left icon
-
+ Leading button
+
Subtitle
-
+
+
+
+ Headline image
+
+
+
+ Center headline and subtitle
+
+
+
+ Filled trailing action
+
Search icon
-
+
More icon
-
+
Calendar icon
@@ -120,73 +322,110 @@ const AppbarExample = () => {
Custom Color
-
- Elevated
-
-
>
);
return (
<>
+
+
+
{renderDefaultOptions()}
-
+ {showFilledTrailingAction ? (
+
+ Style
+ {
+ if (value === 'filled' || value === 'tonal') {
+ setFilledTrailingActionVariant(value);
+ }
+ }}
+ >
+
+ Primary filled
+
+
+
+ Tonal filled
+
+
+
+ Width
+ {
+ if (value === 'default' || value === 'wide') {
+ setFilledTrailingActionWidth(value);
+ }
+ }}
+ >
+
+ Default
+
+
+
+ Wide
+
+
+
+
+ ) : null}
+
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- setAppbarMode(value as AppbarModes)
- }
+ value={appbarConfiguration}
+ onValueChange={(value: string) => {
+ if (
+ value === 'small' ||
+ value === 'search' ||
+ value === 'medium-flexible' ||
+ value === 'large-flexible'
+ ) {
+ setAppbarConfiguration(value);
+ }
+ }}
>
- Small (default)
-
+ Search
+
- Medium
-
+ Small (default)
+
- Large
-
+ Medium flexible
+
- Center-aligned
-
+ Large flexible
+
-
- {}} />
- {}} />
- {}} />
- {}} />
- {renderFAB()}
-
setShowSnackbar(false)}
duration={Snackbar.DURATION_SHORT}
>
- Heading pressed
+ Headline pressed
>
);
@@ -207,17 +446,11 @@ const styles = StyleSheet.create({
paddingVertical: 8,
paddingHorizontal: 16,
},
- bottom: {
- position: 'absolute',
- left: 0,
- right: 0,
- bottom: 0,
- },
- fab: {
- position: 'absolute',
- right: 16,
- },
customColor: {
backgroundColor: Palette.secondary80,
},
+ headlineImage: {
+ width: 32,
+ height: 32,
+ },
});
diff --git a/example/src/Examples/BottomNavigationExample.tsx b/example/src/Examples/BottomNavigationExample.tsx
index b66bb474d6..ea5ead5ace 100644
--- a/example/src/Examples/BottomNavigationExample.tsx
+++ b/example/src/Examples/BottomNavigationExample.tsx
@@ -86,45 +86,53 @@ const BottomNavigationExample = () => {
return (
-
- navigation.goBack()} />
-
-
-
+ navigation.goBack(),
+ }}
+ trailingActions={[
+ {
+ key: 'more',
+ icon: MORE_ICON,
+ 'aria-label': 'More options',
+ onPress: () => setMenuVisible(true),
+ },
+ ]}
+ />
+
{
return (
-
- navigation.goBack()} />
-
-
- }
- >
- {}} title="Undo" />
- {}} title="Redo" />
-
- {}} title="Cut" disabled />
- {}} title="Copy" disabled />
- {}} title="Paste" />
-
-
+ navigation.goBack(),
+ }}
+ trailingActions={[
+ {
+ key: 'more',
+ icon: MORE_ICON,
+ 'aria-label': 'More options',
+ onPress: _toggleMenu('menu1'),
+ decorate: (button) => (
+
+ ),
+ },
+ ]}
+ />
{
return (
-
- navigation.goBack()} />
-
-
+ navigation.goBack(),
+ }}
+ />
{
React.useLayoutEffect(() => {
navigation.setOptions({
header: () => (
-
-
- navigation.goBack()} />
-
-
-
- {}} />
-
-
- {}} />
-
-
- {}} />
-
-
+ navigation.goBack(),
+ decorate: (button) => {button},
+ }}
+ trailingActions={[
+ {
+ key: 'print',
+ icon: 'printer',
+ 'aria-label': 'Print',
+ onPress: () => {},
+ decorate: (button) => (
+ {button}
+ ),
+ },
+ {
+ key: 'search',
+ icon: 'magnify',
+ 'aria-label': 'Search',
+ onPress: () => {},
+ decorate: (button) => {button},
+ },
+ {
+ key: 'more',
+ icon: MORE_ICON,
+ 'aria-label': 'More options',
+ onPress: () => {},
+ decorate: (button) => (
+ {button}
+ ),
+ },
+ ]}
+ />
),
});
});
diff --git a/example/src/RootNavigator.tsx b/example/src/RootNavigator.tsx
index 95ea077275..872ef9b79e 100644
--- a/example/src/RootNavigator.tsx
+++ b/example/src/RootNavigator.tsx
@@ -25,18 +25,20 @@ function Header({ navigation, route, options, back }: NativeStackHeaderProps) {
const drawerNavigation = useNavigation('Home');
return (
-
- {back ? (
- navigation.goBack()} />
- ) : (
- drawerNavigation.openDrawer()}
- />
- )}
-
-
+ navigation.goBack() }
+ : {
+ icon: 'menu',
+ 'aria-label': 'Open navigation menu',
+ onPress: () => drawerNavigation.openDrawer(),
+ }
+ }
+ />
);
}
diff --git a/src/components/Appbar/Appbar.tsx b/src/components/Appbar/Appbar.tsx
index 7e62fd4df9..d2b080ee1d 100644
--- a/src/components/Appbar/Appbar.tsx
+++ b/src/components/Appbar/Appbar.tsx
@@ -1,341 +1,476 @@
import * as React from 'react';
import { StyleSheet, View } from 'react-native';
-import type { ColorValue, StyleProp, ViewProps, ViewStyle } from 'react-native';
+import type { ColorValue, ViewProps } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import AppbarButton from './AppbarButton';
import AppbarContent from './AppbarContent';
+import type { Props } from './types';
import {
- getAppbarBackgroundColor,
+ APPBAR_HEADLINE_IMAGE_HEIGHT,
+ APPBAR_ICON_BUTTON_SIZE,
+ APPBAR_SEARCH_MAX_WIDTH,
getAppbarBorders,
- modeAppbarHeight,
- renderAppbarContent,
- filterAppbarActions,
+ getAppbarHeight,
+ getTrailingActionsWidth,
} from './utils';
-import type { AppbarModes, AppbarChildProps } from './utils';
import { useInternalTheme } from '../../core/theming';
-import type { ThemeProp } from '../../types';
+import Searchbar from '../Searchbar';
import Surface from '../Surface';
-const APPBAR_HORIZONTAL_PADDING = 4;
-
-export type AppbarStyle = Omit;
-
-export type Props = Omit, 'style'> & {
- /**
- * Whether the background color is a dark color. A dark appbar will render light text and vice-versa.
- */
- dark?: boolean;
- /**
- * Content of the `Appbar`.
- */
- children: React.ReactNode;
- /**
- * @supported Available in v5.x with theme version 3
- *
- * Mode of the Appbar.
- * - `small` - Appbar with default height (64).
- * - `medium` - Appbar with medium height (112).
- * - `large` - Appbar with large height (152).
- * - `center-aligned` - Appbar with default height and center-aligned title.
- */
- mode?: 'small' | 'medium' | 'large' | 'center-aligned';
- /**
- * @supported Available in v5.x with theme version 3
- * Whether Appbar background should have the elevation along with primary color pigment.
- */
- elevated?: boolean;
- /**
- * Safe area insets for the Appbar. This can be used to avoid elements like the navigation bar on Android and bottom safe area on iOS.
- */
- safeAreaInsets?: {
- bottom?: number;
- top?: number;
- left?: number;
- right?: number;
- };
- /**
- * @optional
- */
- theme?: ThemeProp;
- style?: StyleProp;
-};
+const EMPTY_TRAILING_ACTIONS = [] as const;
/**
- * A component to display action items in a bar. It can be placed at the top or bottom.
- * The top bar usually contains the screen title, controls such as navigation buttons, menu button etc.
- * The bottom bar usually provides access to a drawer and up to four actions.
- *
- * By default Appbar uses primary color as a background, in dark theme with `adaptive` mode it will use surface colour instead.
- * See [Dark Theme](https://callstack.github.io/react-native-paper/docs/guides/theming#dark-theme) for more informations
+ * A Material Design app bar for displaying a page headline, navigation, and
+ * contextual actions. Appbar supports small, medium flexible, large flexible,
+ * and search variants. Its surface color automatically changes when content
+ * has scrolled, and it accounts for safe-area insets.
*
* ## Usage
- * ### Top bar
+ *
+ * ### Small app bar
* ```js
* import * as React from 'react';
* import { Appbar } from 'react-native-paper';
*
* const MyComponent = () => (
- *
- * {}} />
- *
- * {}} />
- * {}} />
- *
+ * {} }}
+ * trailingActions={[
+ * {
+ * key: 'search',
+ * icon: 'magnify',
+ * 'aria-label': 'Search',
+ * onPress: () => {},
+ * },
+ * {
+ * key: 'more',
+ * icon: 'dots-vertical',
+ * 'aria-label': 'More options',
+ * onPress: () => {},
+ * },
+ * ]}
+ * />
* );
*
* export default MyComponent;
* ```
*
- * ### Bottom bar
+ * ### Decorated action
* ```js
- * import * as React from 'react';
- * import { StyleSheet } from 'react-native';
- * import { Appbar, FAB, useTheme } from 'react-native-paper';
- * import { useSafeAreaInsets } from 'react-native-safe-area-context';
+ * import { Appbar, Tooltip } from 'react-native-paper';
*
- * const BOTTOM_APPBAR_HEIGHT = 80;
- * const MEDIUM_FAB_HEIGHT = 56;
+ * {},
+ * decorate: (button) => (
+ * {button}
+ * ),
+ * },
+ * ]}
+ * />
+ * ```
*
- * const MyComponent = () => {
- * const { bottom } = useSafeAreaInsets();
- * const theme = useTheme();
+ * ### Flexible app bar
+ * ```js
+ *
+ * ```
*
- * return (
- *
- * {}} />
- * {}} />
- * {}} />
- * {}} />
- * {}}
- * style={[
- * styles.fab,
- * { top: (BOTTOM_APPBAR_HEIGHT - MEDIUM_FAB_HEIGHT) / 2 },
- * ]}
- * />
- *
- * );
- * };
+ * ### Search app bar
+ * ```js
+ * {} }}
+ * searchBar={{
+ * placeholder: 'Search messages',
+ * value: query,
+ * onChangeText: setQuery,
+ * }}
+ * />
+ * ```
*
- * const styles = StyleSheet.create({
- * bottom: {
- * backgroundColor: 'aquamarine',
- * position: 'absolute',
- * left: 0,
- * right: 0,
- * bottom: 0,
- * },
- * fab: {
- * position: 'absolute',
- * right: 16,
- * },
- * });
+ * ## Variants
*
- * export default MyComponent;
- * ```
+ * | `variant` | Purpose |
+ * | --- | --- |
+ * | `small` | A compact 64dp app bar with a one-line headline. |
+ * | `medium-flexible` | A flexible app bar with a two-line headline and optional subtitle or image. |
+ * | `large-flexible` | A prominent flexible app bar with a two-line headline and optional subtitle or image. |
+ * | `search` | An app bar containing a centered Paper `Searchbar`. |
+ *
+ * ## Main props
+ *
+ * | Prop | Description |
+ * | --- | --- |
+ * | `headline` | Required accessible headline for every non-search variant. |
+ * | `subtitle` | Optional supporting text for written headlines. |
+ * | `headlineAlignment` | Aligns headline content to `leading` (default) or `center`. |
+ * | `headlineImage` | Replaces the visible small headline, or appears above a flexible headline. Images should fit within 32dp height. |
+ * | `onHeadlinePress` | Makes the headline area interactive; use `headlinePressableProps` for its accessibility state. |
+ * | `leadingButton` | A back button (`{ type: 'back' }`) or standard icon-button configuration. Use `decorate` to wrap it with components such as `Tooltip` or `Menu`. |
+ * | `trailingActions` | Standard icon-button configurations, or exactly one `filled`/`tonal` action with optional `wide` width. Every action requires a stable `key` and `aria-label`, and can use `decorate` to wrap its resolved button. |
+ * | `searchBar` | Required for the `search` variant. Accepts Paper `Searchbar` props except `mode`, `elevation`, `showDivider`, and `theme`. |
+ * | `isScrolled` | Uses `surfaceContainer` instead of `surface` when content has scrolled. |
+ * | `safeAreaInsets` | Overrides detected top, left, or right safe-area insets. |
+ * | `statusBarHeight` | Overrides only the automatic top inset. |
+ * | `contentStyle` | Styles the headline and subtitle area. |
+ * | `style` | Styles the app bar and can override its background color. |
+ *
+ * ## Migrating from the compound API
+ *
+ * `Appbar.Header`, `Appbar.Content`, `Appbar.Action`, and
+ * `Appbar.BackAction` have been removed. Render one `Appbar` and provide its
+ * headline, leading button, and trailing actions as props. The former
+ * `mode="medium"` and `mode="large"` values are now
+ * `variant="medium-flexible"` and `variant="large-flexible"`; centered
+ * content uses `headlineAlignment="center"`.
+ *
+ * ## Bottom toolbar support
+ *
+ * Material Design 3 drops the bottom bar support contained previously in the Appbar scope
+ * and moves it to Toolbars, hence you can't use the component to construct a bottom bar
+ * anymore - for these cases please use the Toolbar component.
+ *
+ * @extends View props https://reactnative.dev/docs/view#props
*/
const Appbar = ({
- children,
- dark,
- style,
- mode = 'small',
- elevated = false,
+ contentStyle,
+ headline,
+ headlineAlignment = 'leading',
+ headlineImage,
+ headlinePressableProps,
+ headlineProps,
+ isScrolled = false,
+ leadingButton,
+ onHeadlinePress,
safeAreaInsets,
+ searchBar,
+ statusBarHeight,
+ style,
+ subtitle,
+ subtitleProps,
+ testID,
theme: themeOverrides,
+ trailingActions = EMPTY_TRAILING_ACTIONS,
+ variant,
+ ref,
...rest
}: Props) => {
const theme = useInternalTheme(themeOverrides);
- const flattenedStyle = StyleSheet.flatten(style);
- const { backgroundColor: customBackground, ...restStyle } = (flattenedStyle ||
- {}) as Exclude & {
- backgroundColor?: ColorValue;
- };
+ const detectedInsets = useSafeAreaInsets();
+ const { customBackground, restStyle, borderStyles } = React.useMemo(() => {
+ const flattenedStyle = StyleSheet.flatten(style);
- const backgroundColor = getAppbarBackgroundColor(
- theme,
- elevated,
- customBackground
- );
+ const resolvedStyle = (flattenedStyle || {}) as Exclude<
+ typeof flattenedStyle,
+ number
+ > & {
+ backgroundColor?: ColorValue;
+ };
+ const { backgroundColor, ...remainingStyle } = resolvedStyle;
- const borderStyles = getAppbarBorders(restStyle);
+ return {
+ customBackground: backgroundColor,
+ restStyle: remainingStyle,
+ borderStyles: getAppbarBorders(remainingStyle),
+ };
+ }, [style]);
+ const backgroundColor =
+ customBackground ??
+ (isScrolled ? theme.colors.surfaceContainer : theme.colors.surface);
+ const hasSubtitle = typeof subtitle === 'string' && subtitle.length > 0;
+ const minHeight = getAppbarHeight(variant, hasSubtitle);
+ const topInset = statusBarHeight ?? safeAreaInsets?.top ?? detectedInsets.top;
+ const leftInset = safeAreaInsets?.left ?? detectedInsets.left;
+ const rightInset = safeAreaInsets?.right ?? detectedInsets.right;
+ const horizontalInset = Math.max(leftInset, rightInset);
+ const centered = headlineAlignment === 'center';
+ const sideWidth = Math.max(
+ leadingButton ? APPBAR_ICON_BUTTON_SIZE : 0,
+ getTrailingActionsWidth(trailingActions)
+ );
+ const sideStyle = React.useMemo(() => ({ width: sideWidth }), [sideWidth]);
+ const surfaceStyle = React.useMemo(
+ () => ({
+ paddingTop: topInset,
+ paddingHorizontal: horizontalInset,
+ }),
+ [horizontalInset, topInset]
+ );
+ const appbarStyle = React.useMemo(
+ () => [styles.appbar, { backgroundColor, minHeight }, restStyle],
+ [backgroundColor, minHeight, restStyle]
+ );
+ const resolvedSearchInputStyle = React.useMemo(
+ () => [{ color: theme.colors.onSurface }, searchBar?.inputStyle],
+ [searchBar?.inputStyle, theme.colors.onSurface]
+ );
+ const searchBackgroundColor = isScrolled
+ ? theme.colors.surfaceContainerHighest
+ : theme.colors.surfaceContainer;
+ const resolvedSearchStyle = React.useMemo(
+ () => [styles.searchBar, searchBar?.style],
+ [searchBar?.style]
+ );
+ const searchTheme = React.useMemo(
+ () => ({
+ ...theme,
+ colors: {
+ ...theme.colors,
+ surfaceContainerHigh: searchBackgroundColor,
+ },
+ }),
+ [searchBackgroundColor, theme]
+ );
+ const {
+ accessibilityLabel: _accessibilityLabel,
+ accessibilityRole: _accessibilityRole,
+ accessible: _accessible,
+ 'aria-label': _ariaLabel,
+ role: _role,
+ ...viewProps
+ } = rest as ViewProps;
- const isMode = (modeToCompare: AppbarModes) => {
- return mode === modeToCompare;
- };
+ const renderLeadingButton = () =>
+ leadingButton ? (
+
+ ) : null;
- const isDark = typeof dark === 'boolean' ? dark : false;
+ const renderTrailingActions = () =>
+ trailingActions.map((action) => (
+
+ ));
- const isCenterAlignedMode = isMode('center-aligned');
+ const renderSearchAppbar = () => {
+ if (!searchBar) {
+ return null;
+ }
- let shouldCenterContent = false;
- let shouldAddLeftSpacing = false;
- let shouldAddRightSpacing = false;
- if (isCenterAlignedMode) {
- let hasAppbarContent = false;
- let leftItemsCount = 0;
- let rightItemsCount = 0;
+ const {
+ inputStyle: _searchInputStyle,
+ style: _searchStyle,
+ testID: searchTestID = `${testID}-search`,
+ ...searchProps
+ } = searchBar;
- React.Children.forEach(children, (child) => {
- if (React.isValidElement(child)) {
- const isLeading = child.props.isLeading === true;
+ return (
+
+ {renderLeadingButton()}
+
+
+
+
+
+ {renderTrailingActions()}
+
+ );
+ };
- if (child.type === AppbarContent) {
- hasAppbarContent = true;
- } else if (isLeading || !hasAppbarContent) {
- leftItemsCount++;
- } else {
- rightItemsCount++;
+ const contentProps =
+ variant !== 'search'
+ ? {
+ alignment: headlineAlignment,
+ contentStyle,
+ headline,
+ headlineImage,
+ headlinePressableProps,
+ headlineProps,
+ onHeadlinePress,
+ subtitle,
+ subtitleProps,
+ testID: `${testID}-content`,
+ theme,
+ variant,
}
- }
- });
+ : null;
- shouldCenterContent =
- hasAppbarContent && leftItemsCount < 2 && rightItemsCount < 3;
- shouldAddLeftSpacing = shouldCenterContent && leftItemsCount === 0;
- shouldAddRightSpacing = shouldCenterContent && rightItemsCount === 0;
- }
+ const renderFlexibleHeadlineImage = () =>
+ headlineImage ? (
+
+ {headlineImage}
+
+ ) : null;
- const spacingStyle = styles.v3Spacing;
+ const renderSmallAppbar = () => {
+ if (!contentProps) {
+ return null;
+ }
- const insets = {
- paddingBottom: safeAreaInsets?.bottom,
- paddingTop: safeAreaInsets?.top,
- paddingLeft: (safeAreaInsets?.left ?? 0) + APPBAR_HORIZONTAL_PADDING,
- paddingRight: (safeAreaInsets?.right ?? 0) + APPBAR_HORIZONTAL_PADDING,
+ if (centered || headlineImage) {
+ return (
+
+ {renderLeadingButton()}
+
+
+ {renderTrailingActions()}
+
+
+ );
+ }
+
+ return (
+
+ {renderLeadingButton()}
+
+ {renderTrailingActions()}
+
+ );
+ };
+
+ const renderFlexibleAppbar = () => {
+ if (!contentProps) {
+ return null;
+ }
+
+ return (
+
+ {headlineImage ? (
+
+
+ {renderLeadingButton()}
+
+ {renderFlexibleHeadlineImage()}
+
+ {renderTrailingActions()}
+
+
+ ) : (
+
+ {renderLeadingButton()}
+
+ {renderTrailingActions()}
+
+
+ )}
+
+
+ );
};
return (
- {shouldAddLeftSpacing ? : null}
- {(isMode('small') || isMode('center-aligned')) && (
- <>
- {/* Render only the back action at first place */}
- {renderAppbarContent({
- children,
- isDark,
- theme,
- renderOnly: ['Appbar.BackAction'],
- shouldCenterContent: isCenterAlignedMode || shouldCenterContent,
- })}
- {/* Render the rest of the content except the back action */}
- {renderAppbarContent({
- // Filter appbar actions - first leading icons, then trailing icons
- children: [
- ...filterAppbarActions(children, true),
- ...filterAppbarActions(children),
- ],
- isDark,
- theme,
- renderExcept: ['Appbar.BackAction'],
- shouldCenterContent: isCenterAlignedMode || shouldCenterContent,
- })}
- >
- )}
- {(isMode('medium') || isMode('large')) && (
-
- {/* Appbar top row with controls */}
-
- {/* Left side of row container, can contain AppbarBackAction or AppbarAction if it's leading icon */}
- {renderAppbarContent({
- children,
- isDark,
- renderOnly: ['Appbar.BackAction'],
- mode,
- })}
- {renderAppbarContent({
- children: filterAppbarActions(children, true),
- isDark,
- renderOnly: ['Appbar.Action'],
- mode,
- })}
- {/* Right side of row container, can contain other AppbarAction if they are not leading icons */}
-
- {renderAppbarContent({
- children: filterAppbarActions(children),
- isDark,
- renderExcept: [
- 'Appbar',
- 'Appbar.BackAction',
- 'Appbar.Content',
- 'Appbar.Header',
- ],
- mode,
- })}
-
-
- {renderAppbarContent({
- children,
- isDark,
- renderOnly: ['Appbar.Content'],
- mode,
- })}
-
- )}
- {shouldAddRightSpacing ? : null}
+
+ {variant === 'search'
+ ? renderSearchAppbar()
+ : variant === 'small'
+ ? renderSmallAppbar()
+ : renderFlexibleAppbar()}
+
);
};
const styles = StyleSheet.create({
appbar: {
+ paddingHorizontal: 4,
+ },
+ smallRow: {
+ flex: 1,
flexDirection: 'row',
alignItems: 'center',
},
- v3Spacing: {
- width: 52,
- },
- controlsRow: {
+ searchRow: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
+ },
+ searchSlot: {
+ flex: 1,
+ marginHorizontal: 8,
+ alignItems: 'center',
+ },
+ searchBar: {
+ width: '100%',
+ },
+ searchWidthLimiter: {
+ width: '100%',
+ maxWidth: APPBAR_SEARCH_MAX_WIDTH,
+ },
+ flexibleContainer: {
+ flex: 1,
+ },
+ controlsRow: {
+ minHeight: 56,
+ flexDirection: 'row',
+ alignItems: 'flex-start',
justifyContent: 'space-between',
},
- rightActionControls: {
+ trailingActions: {
flexDirection: 'row',
- flex: 1,
justifyContent: 'flex-end',
},
- columnContainer: {
- flexDirection: 'column',
- flex: 1,
- paddingTop: 8,
+ side: {
+ flexDirection: 'row',
+ },
+ headlineLeading: {
+ marginStart: 12,
},
- centerAlignedContainer: {
- paddingTop: 0,
+ headlineWithLeading: {
+ marginStart: 4,
+ },
+ flexibleHeadlineImage: {
+ flex: 1,
+ height: APPBAR_HEADLINE_IMAGE_HEIGHT,
+ maxWidth: '100%',
+ marginTop: 12,
+ alignItems: 'center',
+ justifyContent: 'center',
+ overflow: 'hidden',
},
});
-export default Appbar;
+const MemoizedAppbar = React.memo(Appbar);
+
+export default MemoizedAppbar;
// @component-docs ignore-next-line
-export { Appbar };
+export { MemoizedAppbar as Appbar };
diff --git a/src/components/Appbar/AppbarAction.tsx b/src/components/Appbar/AppbarAction.tsx
deleted file mode 100644
index c6d68a362e..0000000000
--- a/src/components/Appbar/AppbarAction.tsx
+++ /dev/null
@@ -1,113 +0,0 @@
-import * as React from 'react';
-import type { ColorValue, StyleProp, View, ViewStyle } from 'react-native';
-
-import type { AnimatedStyle } from 'react-native-reanimated';
-
-import { useInternalTheme } from '../../core/theming';
-import type { ThemeProp } from '../../types';
-import type { IconSource } from '../Icon';
-import IconButton from '../IconButton/IconButton';
-
-export type Props = React.ComponentPropsWithoutRef & {
- /**
- * Custom color for action icon.
- */
- color?: ColorValue;
- /**
- * Name of the icon to show.
- */
- icon: IconSource;
- /**
- * Optional icon size.
- */
- size?: number;
- /**
- * Whether the button is disabled. A disabled button is greyed out and `onPress` is not called on touch.
- */
- disabled?: boolean;
- /**
- * Accessibility label for the button. This is read by the screen reader when the user taps the button.
- */
- 'aria-label'?: string;
- /**
- * Function to execute on press.
- */
- onPress?: () => void;
- /**
- * @supported Available in v5.x with theme version 3
- *
- * Whether it's the leading button. Note: If `Appbar.BackAction` is present, it will be rendered before any `isLeading` icons.
- */
- isLeading?: boolean;
- style?: StyleProp>;
- ref?: React.Ref;
- /**
- * @optional
- */
- theme?: ThemeProp;
-};
-
-/**
- * A component used to display an action item in the appbar.
- *
- * ## Usage
- * ```js
- * import * as React from 'react';
- * import { Appbar } from 'react-native-paper';
- * import { Platform } from 'react-native';
- *
- * const MORE_ICON = Platform.OS === 'ios' ? 'dots-horizontal' : 'dots-vertical';
- *
- * const MyComponent = () => (
- *
- *
- * {}} />
- * {}} />
- *
- * );
- *
- * export default MyComponent;
- * ```
- */
-const AppbarAction = ({
- size = 24,
- color: iconColor,
- icon,
- disabled,
- onPress,
- 'aria-label': ariaLabel,
- isLeading,
- theme: themeOverrides,
- ref,
- ...rest
-}: Props) => {
- const theme = useInternalTheme(themeOverrides);
- const { colors } = theme;
-
- const actionIconColor = iconColor
- ? iconColor
- : isLeading
- ? colors.onSurface
- : colors.onSurfaceVariant;
-
- return (
-
- );
-};
-
-AppbarAction.displayName = 'Appbar.Action';
-
-export default AppbarAction;
-
-// @component-docs ignore-next-line
-export { AppbarAction };
diff --git a/src/components/Appbar/AppbarBackAction.tsx b/src/components/Appbar/AppbarBackAction.tsx
deleted file mode 100644
index c4249bd18a..0000000000
--- a/src/components/Appbar/AppbarBackAction.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-import * as React from 'react';
-import type {
- ColorValue,
- GestureResponderEvent,
- StyleProp,
- View,
- ViewStyle,
-} from 'react-native';
-
-import type { AnimatedStyle } from 'react-native-reanimated';
-
-import type { $Omit } from './../../types';
-import AppbarAction from './AppbarAction';
-import AppbarBackIcon from './AppbarBackIcon';
-
-export type Props = $Omit<
- React.ComponentPropsWithoutRef,
- 'icon'
-> & {
- /**
- * Custom color for back icon.
- */
- color?: ColorValue;
- /**
- * Optional icon size.
- */
- size?: number;
- /**
- * Whether the button is disabled. A disabled button is greyed out and `onPress` is not called on touch.
- */
- disabled?: boolean;
- /**
- * Accessibility label for the button. This is read by the screen reader when the user taps the button.
- */
- 'aria-label'?: string;
- /**
- * Function to execute on press.
- */
- onPress?: (e: GestureResponderEvent) => void;
- style?: StyleProp>;
- ref?: React.Ref;
-};
-
-/**
- * A component used to display a back button in the appbar.
- *
- * ## Usage
- * ```js
- * import * as React from 'react';
- * import { Appbar } from 'react-native-paper';
- *
- * const MyComponent = () => (
- *
- * {}} />
- *
- * );
- *
- * export default MyComponent;
- * ```
- */
-const AppbarBackAction = ({
- 'aria-label': ariaLabel = 'Back',
- ref,
- ...rest
-}: Props) => (
-
-);
-
-AppbarBackAction.displayName = 'Appbar.BackAction';
-
-export default AppbarBackAction;
-
-// @component-docs ignore-next-line
-export { AppbarBackAction };
diff --git a/src/components/Appbar/AppbarButton.tsx b/src/components/Appbar/AppbarButton.tsx
new file mode 100644
index 0000000000..a149e1f246
--- /dev/null
+++ b/src/components/Appbar/AppbarButton.tsx
@@ -0,0 +1,68 @@
+import { StyleSheet } from 'react-native';
+
+import AppbarBackIcon from './AppbarBackIcon';
+import type { AppbarLeadingButton, AppbarTrailingAction } from './types';
+import type { Theme } from '../../types';
+import IconButton from '../IconButton/IconButton';
+
+type Props = {
+ button: AppbarTrailingAction | AppbarLeadingButton;
+ leading?: boolean;
+ theme: Theme;
+};
+
+const AppbarButton = ({ button, leading = false, theme }: Props) => {
+ const {
+ color,
+ decorate,
+ key: _key,
+ style,
+ variant = 'standard',
+ width,
+ ...rest
+ } = button;
+ const { type: _type, ...buttonProps } = rest as typeof rest & {
+ type?: 'back' | 'icon';
+ };
+ const isBackButton = 'type' in button && button.type === 'back';
+ const mode =
+ variant === 'filled'
+ ? 'contained'
+ : variant === 'tonal'
+ ? 'contained-tonal'
+ : undefined;
+ const iconColor =
+ color ??
+ (mode
+ ? undefined
+ : leading
+ ? theme.colors.onSurface
+ : theme.colors.onSurfaceVariant);
+
+ const iconButton = (
+
+ );
+
+ return decorate ? decorate(iconButton) : iconButton;
+};
+
+const styles = StyleSheet.create({
+ wideButton: {
+ width: 56,
+ },
+});
+
+export default AppbarButton;
diff --git a/src/components/Appbar/AppbarContent.tsx b/src/components/Appbar/AppbarContent.tsx
index 5270aa271d..b5f84d418f 100644
--- a/src/components/Appbar/AppbarContent.tsx
+++ b/src/components/Appbar/AppbarContent.tsx
@@ -1,192 +1,215 @@
import * as React from 'react';
-import { StyleSheet, Pressable, View } from 'react-native';
-import type {
- GestureResponderEvent,
- StyleProp,
- TextStyle,
- ViewStyle,
- ViewProps,
-} from 'react-native';
+import { Pressable, StyleSheet, View } from 'react-native';
+import type { StyleProp, ViewStyle } from 'react-native';
-import { modeTextVariant } from './utils';
-import { useInternalTheme } from '../../core/theming';
-import type { $RemoveChildren, ThemeProp } from '../../types';
+import type {
+ AppbarHeadlineAlignment,
+ AppbarHeadlineVariant,
+ Props as AppbarProps,
+} from './types';
+import { APPBAR_HEADLINE_IMAGE_HEIGHT } from './utils';
+import type { Theme, TypescaleKey } from '../../types';
import Text from '../Typography/Text';
-import type { TextRef } from '../Typography/Text';
-type TitleString = {
- title: string;
- titleStyle?: StyleProp;
+type Props = Pick<
+ AppbarProps,
+ | 'contentStyle'
+ | 'headline'
+ | 'headlineImage'
+ | 'headlinePressableProps'
+ | 'headlineProps'
+ | 'onHeadlinePress'
+ | 'subtitle'
+ | 'subtitleProps'
+> & {
+ alignment: AppbarHeadlineAlignment;
+ theme: Theme;
+ variant: AppbarHeadlineVariant;
+ style?: StyleProp;
+ testID: string;
};
-type TitleElement = { title: React.ReactNode; titleStyle?: never };
+const headlineVariants: Record = {
+ small: 'titleLarge',
+ 'medium-flexible': 'headlineMedium',
+ 'large-flexible': 'displaySmall',
+};
-export type Props = $RemoveChildren & {
- // For `title` and `titleStyle` props their types are duplicated due to the generation of documentation.
- // Appropriate type for them are either `TitleString` or `TitleElement`, depends on `title` type.
- /**
- * Text or component for the title.
- */
- title: React.ReactNode;
- /**
- * Style for the title, if `title` is a string.
- */
- titleStyle?: StyleProp;
- /**
- * Reference for the title.
- */
- titleRef?: React.RefObject;
- /**
- * Function to execute on press.
- */
- onPress?: (e: GestureResponderEvent) => void;
- /**
- * If true, disable all interactions for this component.
- */
- disabled?: boolean;
- /**
- * Custom color for the text.
- */
- color?: string;
- /**
- * Specifies the largest possible scale a title font can reach.
- */
- titleMaxFontSizeMultiplier?: number;
- /**
- * @internal
- */
- mode?: 'small' | 'medium' | 'large' | 'center-aligned';
- style?: StyleProp;
- /**
- * @optional
- */
- theme?: ThemeProp;
- /**
- * testID to be used on tests.
- */
- testID?: string;
-} & (TitleString | TitleElement);
+const subtitleVariants: Record = {
+ small: 'labelMedium',
+ 'medium-flexible': 'labelLarge',
+ 'large-flexible': 'titleMedium',
+};
+
+const subtitleSpacing: Record = {
+ small: 0,
+ 'medium-flexible': 4,
+ 'large-flexible': 8,
+};
-/**
- * A component used to display a title in an appbar.
- *
- * ## Usage
- * ```js
- * import * as React from 'react';
- * import { Appbar } from 'react-native-paper';
- *
- * const MyComponent = () => (
- *
- *
- *
- * );
- *
- * export default MyComponent;
- * ```
- */
const AppbarContent = ({
- color: titleColor,
- onPress,
- disabled,
+ alignment,
+ contentStyle,
+ headline,
+ headlineImage,
+ headlinePressableProps,
+ headlineProps,
+ onHeadlinePress,
+ subtitle,
+ subtitleProps,
+ testID,
+ theme,
+ variant,
style,
- titleRef,
- titleStyle,
- title,
- titleMaxFontSizeMultiplier,
- mode = 'small',
- theme: themeOverrides,
- testID = 'appbar-content',
- ...rest
}: Props) => {
- const theme = useInternalTheme(themeOverrides);
- const { colors, fonts } = theme;
-
- const titleTextColor = titleColor ? titleColor : colors.onSurface;
-
- const modeContainerStyles = {
- small: styles.v3DefaultContainer,
- medium: styles.v3MediumContainer,
- large: styles.v3LargeContainer,
- 'center-aligned': styles.v3DefaultContainer,
- };
-
- const variant = modeTextVariant[mode];
-
- const contentWrapperProps = {
- pointerEvents: 'box-none',
- style: [styles.container, modeContainerStyles[mode], style],
- testID,
- ...rest,
- } satisfies ViewProps;
+ const headlineVariant = headlineVariants[variant];
+ const subtitleVariant = subtitleVariants[variant];
+ const centered = alignment === 'center';
+ const hasHeadlineImage = variant === 'small' && Boolean(headlineImage);
+ const headlineStyle = React.useMemo(
+ () => [
+ styles.text,
+ centered && styles.centeredText,
+ { color: theme.colors.onSurface },
+ headlineProps?.style,
+ ],
+ [centered, headlineProps?.style, theme.colors.onSurface]
+ );
+ const subtitleStyle = React.useMemo(
+ () => [
+ styles.text,
+ centered && styles.centeredText,
+ { marginTop: subtitleSpacing[variant] },
+ { color: theme.colors.onSurfaceVariant },
+ subtitleProps?.style,
+ ],
+ [centered, subtitleProps?.style, theme.colors.onSurfaceVariant, variant]
+ );
+ const wrapperStyle = React.useMemo(
+ () => [
+ styles.container,
+ variant !== 'small' && styles.flexibleContainer,
+ centered && styles.centeredContainer,
+ style,
+ contentStyle,
+ ],
+ [centered, contentStyle, style, variant]
+ );
- const content = (
+ const content = hasHeadlineImage ? (
+
+ {headlineImage}
+
+ ) : (
<>
- {typeof title === 'string' ? (
+
+ {headline}
+
+ {subtitle ? (
- {title}
+ {subtitle}
- ) : (
- title
- )}
+ ) : null}
>
);
- if (onPress) {
+ const wrapperProps = {
+ testID,
+ style: wrapperStyle,
+ };
+
+ if (onHeadlinePress) {
+ const {
+ 'aria-label': ariaLabel,
+ accessibilityLabel,
+ disabled,
+ ...restHeadlinePressableProps
+ } = headlinePressableProps ?? {};
+
return (
{content}
);
}
- return {content};
+ return (
+
+ {content}
+
+ );
};
-AppbarContent.displayName = 'Appbar.Content';
-
const styles = StyleSheet.create({
container: {
flex: 1,
- paddingHorizontal: 12,
+ justifyContent: 'center',
+ minWidth: 0,
},
- v3DefaultContainer: {
- paddingHorizontal: 0,
+ flexibleContainer: {
+ flex: 0,
+ paddingHorizontal: 16,
+ paddingTop: 8,
+ paddingBottom: 12,
},
- v3MediumContainer: {
- paddingHorizontal: 0,
- justifyContent: 'flex-end',
- paddingBottom: 24,
+ centeredContainer: {
+ alignItems: 'center',
},
- v3LargeContainer: {
- paddingHorizontal: 0,
- paddingTop: 36,
- justifyContent: 'flex-end',
- paddingBottom: 28,
+ text: {
+ alignSelf: 'stretch',
+ },
+ centeredText: {
+ textAlign: 'center',
+ },
+ headlineImage: {
+ height: APPBAR_HEADLINE_IMAGE_HEIGHT,
+ maxWidth: '100%',
+ alignItems: 'center',
+ justifyContent: 'center',
+ overflow: 'hidden',
},
});
-export default AppbarContent;
-
-// @component-docs ignore-next-line
-export { AppbarContent };
+export default React.memo(AppbarContent);
diff --git a/src/components/Appbar/AppbarHeader.tsx b/src/components/Appbar/AppbarHeader.tsx
deleted file mode 100644
index 1c486f9ccd..0000000000
--- a/src/components/Appbar/AppbarHeader.tsx
+++ /dev/null
@@ -1,149 +0,0 @@
-import * as React from 'react';
-import { Platform, StyleSheet } from 'react-native';
-import type { ColorValue, StyleProp } from 'react-native';
-
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
-
-import { Appbar } from './Appbar';
-import type { AppbarStyle } from './Appbar';
-import { getAppbarBackgroundColor, modeAppbarHeight } from './utils';
-import { useInternalTheme } from '../../core/theming';
-import type { ThemeProp } from '../../types';
-
-export type Props = Omit<
- React.ComponentProps,
- 'safeAreaInsets' | 'style'
-> & {
- /**
- * Whether the background color is a dark color. A dark header will render light text and vice-versa.
- */
- dark?: boolean;
- /**
- * Extra padding to add at the top of header to account for translucent status bar.
- * This is automatically handled on iOS >= 11 including iPhone X using `SafeAreaView`.
- * If you are using Expo, we assume translucent status bar and set a height for status bar automatically.
- * Pass `0` or a custom value to disable the default behaviour, and customize the height.
- */
- statusBarHeight?: number;
- /**
- * Content of the header.
- */
- children: React.ReactNode;
- /**
- * @supported Available in v5.x with theme version 3
- *
- * Mode of the Appbar.
- * - `small` - Appbar with default height (64).
- * - `medium` - Appbar with medium height (112).
- * - `large` - Appbar with large height (152).
- * - `center-aligned` - Appbar with default height and center-aligned title.
- */
- mode?: 'small' | 'medium' | 'large' | 'center-aligned';
- /**
- * @supported Available in v5.x with theme version 3
- * Whether Appbar background should have the elevation along with primary color pigment.
- */
- elevated?: boolean;
- /**
- * @optional
- */
- theme?: ThemeProp;
- style?: StyleProp;
-};
-
-/**
- * A component to use as a header at the top of the screen.
- * It can contain the screen title, controls such as navigation buttons, menu button etc.
- *
- * ## Usage
- * ```js
- * import * as React from 'react';
- * import { Appbar } from 'react-native-paper';
- *
- * const MyComponent = () => {
- * const _goBack = () => console.log('Went back');
- *
- * const _handleSearch = () => console.log('Searching');
- *
- * const _handleMore = () => console.log('Shown more');
- *
- * return (
- *
- *
- *
- *
- *
- *
- * );
- * };
- *
- * export default MyComponent;
- * ```
- */
-const AppbarHeader = ({
- // Don't use default props since we check it to know whether we should use SafeAreaView
- statusBarHeight,
- style,
- dark,
- mode = Platform.OS === 'ios' ? 'center-aligned' : 'small',
- elevated = false,
- theme: themeOverrides,
- testID = 'appbar-header',
- ...rest
-}: Props) => {
- const theme = useInternalTheme(themeOverrides);
-
- const flattenedStyle = StyleSheet.flatten(style);
- const {
- height = modeAppbarHeight[mode],
- backgroundColor: customBackground,
- zIndex = elevated ? 1 : 0,
- ...restStyle
- } = (flattenedStyle || {}) as Exclude & {
- height?: AppbarStyle['height'];
- backgroundColor?: ColorValue;
- zIndex?: number;
- };
-
- const backgroundColor = getAppbarBackgroundColor(
- theme,
- elevated,
- customBackground
- );
-
- const { top, left, right } = useSafeAreaInsets();
- const topInset = statusBarHeight ?? top;
- const horizontalInset = Math.max(left, right);
- const headerHeight = typeof height === 'number' ? height + topInset : height;
-
- return (
-
- );
-};
-
-AppbarHeader.displayName = 'Appbar.Header';
-
-export default AppbarHeader;
-
-// @component-docs ignore-next-line
-export { AppbarHeader };
diff --git a/src/components/Appbar/index.ts b/src/components/Appbar/index.ts
index 498c8761f2..c996944de4 100644
--- a/src/components/Appbar/index.ts
+++ b/src/components/Appbar/index.ts
@@ -1,22 +1,17 @@
-import AppbarComponent from './Appbar';
-import AppbarAction from './AppbarAction';
-import AppbarBackAction from './AppbarBackAction';
-import AppbarContent from './AppbarContent';
-import AppbarHeader from './AppbarHeader';
-
-const Appbar = Object.assign(
- // @component ./Appbar.tsx
- AppbarComponent,
- {
- // @component ./AppbarContent.tsx
- Content: AppbarContent,
- // @component ./AppbarAction.tsx
- Action: AppbarAction,
- // @component ./AppbarBackAction.tsx
- BackAction: AppbarBackAction,
- // @component ./AppbarHeader.tsx
- Header: AppbarHeader,
- }
-);
-
-export default Appbar;
+export { default } from './Appbar';
+export { Appbar } from './Appbar';
+export type {
+ AppbarActionDecorator,
+ AppbarFilledTrailingAction,
+ AppbarHeadlineAlignment,
+ AppbarHeadlinePressableProps,
+ AppbarHeadlineTextProps,
+ AppbarLeadingButton,
+ AppbarSearchbarProps,
+ AppbarStandardTrailingAction,
+ AppbarTextProps,
+ AppbarTrailingAction,
+ AppbarTrailingActions,
+ AppbarVariant,
+ Props,
+} from './types';
diff --git a/src/components/Appbar/types.ts b/src/components/Appbar/types.ts
new file mode 100644
index 0000000000..482b49bccb
--- /dev/null
+++ b/src/components/Appbar/types.ts
@@ -0,0 +1,238 @@
+import * as React from 'react';
+import type {
+ ColorValue,
+ GestureResponderEvent,
+ StyleProp,
+ TextStyle,
+ View,
+ ViewProps,
+ ViewStyle,
+} from 'react-native';
+
+import type { ThemeProp } from '../../types';
+import type { IconSource } from '../Icon';
+import type { Props as IconButtonProps } from '../IconButton/IconButton';
+import type { Props as SearchbarProps } from '../Searchbar';
+import type { TextRef } from '../Typography/Text';
+
+export type AppbarVariant =
+ | 'search'
+ | 'small'
+ | 'medium-flexible'
+ | 'large-flexible';
+
+export type AppbarHeadlineVariant = Exclude;
+
+export type AppbarHeadlineAlignment = 'leading' | 'center';
+
+export type AppbarStyle = Omit;
+
+export type AppbarTextProps = {
+ /** Style applied to the text. */
+ style?: StyleProp;
+ /** Specifies the largest possible scale the font can reach. */
+ maxFontSizeMultiplier?: number;
+};
+
+export type AppbarHeadlineTextProps = AppbarTextProps & {
+ /** Reference for the headline heading. */
+ ref?: React.RefObject;
+};
+
+export type AppbarHeadlinePressableProps = Pick<
+ ViewProps,
+ | 'accessibilityHint'
+ | 'accessibilityLabel'
+ | 'accessibilityState'
+ | 'aria-busy'
+ | 'aria-disabled'
+ | 'aria-expanded'
+ | 'aria-label'
+ | 'aria-labelledby'
+ | 'aria-selected'
+> & {
+ /** Whether the headline interaction is disabled. */
+ disabled?: boolean;
+};
+
+/** Decorates the resolved icon button without replacing its action configuration. */
+export type AppbarActionDecorator = (
+ button: React.ReactElement
+) => React.ReactElement;
+
+type AppbarTrailingActionBase = Omit<
+ IconButtonProps,
+ 'icon' | 'iconColor' | 'mode' | 'selected' | 'size' | 'theme' | 'aria-label'
+> & {
+ /** Stable key used when rendering an action from the trailing actions array. */
+ key: React.Key;
+ /** Icon displayed by the trailing action. */
+ icon: IconSource;
+ /** Accessible label announced for the icon button. */
+ 'aria-label': string;
+ /** Custom trailing action icon color. */
+ color?: ColorValue;
+ /** Wraps the resolved icon button, for example with a Tooltip or Menu. */
+ decorate?: AppbarActionDecorator;
+};
+
+export type AppbarStandardTrailingAction = AppbarTrailingActionBase & {
+ variant?: 'standard';
+ width?: never;
+};
+
+export type AppbarFilledTrailingAction = AppbarTrailingActionBase & {
+ /** Filled primary and tonal icons use the corresponding IconButton colors. */
+ variant: 'filled' | 'tonal';
+ /** Expressive filled icons can use the default or wide container. */
+ width?: 'default' | 'wide';
+};
+
+export type AppbarTrailingAction =
+ | AppbarStandardTrailingAction
+ | AppbarFilledTrailingAction;
+
+/** A filled trailing action replaces the standard action group and is valid on its own. */
+export type AppbarTrailingActions =
+ | readonly AppbarStandardTrailingAction[]
+ | readonly [AppbarFilledTrailingAction];
+
+type AppbarLeadingIconButton = Omit & {
+ key?: never;
+ type?: 'icon';
+};
+
+type AppbarBackButton = Omit<
+ AppbarStandardTrailingAction,
+ 'aria-label' | 'icon' | 'key' | 'type'
+> & {
+ /** Uses the platform-aware Paper back icon. */
+ type: 'back';
+ icon?: never;
+ key?: never;
+ 'aria-label'?: string;
+};
+
+export type AppbarLeadingButton = AppbarLeadingIconButton | AppbarBackButton;
+
+type AppbarWrittenHeadline = {
+ /** Written headline displayed by the app bar. */
+ headline: string;
+ /** Optional supporting text displayed below the headline. */
+ subtitle?: string;
+ /** Props applied to the headline heading. */
+ headlineProps?: AppbarHeadlineTextProps;
+ /** Props applied to the subtitle text. */
+ subtitleProps?: AppbarTextProps;
+};
+
+type AppbarHeadlineImage = {
+ /** Image or logo displayed in the app bar. It should fit within 32dp height. */
+ headlineImage: React.ReactElement;
+};
+
+type AppbarTextHeadline = AppbarWrittenHeadline & {
+ /** Visual and layout variant of the app bar. */
+ variant: AppbarHeadlineVariant;
+ headlineImage?: never;
+};
+
+type AppbarSmallImageHeadline = AppbarHeadlineImage & {
+ /** Visual and layout variant of the app bar. */
+ variant: 'small';
+ /** Accessible page headline. The image replaces this text visually. */
+ headline: string;
+ subtitle?: never;
+ headlineProps?: never;
+ subtitleProps?: never;
+};
+
+type AppbarFlexibleImageHeadline = AppbarWrittenHeadline &
+ AppbarHeadlineImage & {
+ /** Visual and layout variant of the app bar. */
+ variant: Exclude;
+ };
+
+type AppbarBaseProps = Omit<
+ ViewProps,
+ 'accessibilityLabel' | 'accessibilityRole' | 'children' | 'style' | 'testID'
+> & {
+ /** Optional leading button. */
+ leadingButton?: AppbarLeadingButton;
+ /** Uses the on-scroll container color when true. */
+ isScrolled?: boolean;
+ /** Override for the automatic top safe-area inset. */
+ statusBarHeight?: number;
+ /** Safe-area inset overrides. Unspecified values use the detected insets. */
+ safeAreaInsets?: {
+ top?: number;
+ left?: number;
+ right?: number;
+ };
+ /** Style applied to the app bar container. */
+ style?: StyleProp;
+ /** Reference for the app bar container. */
+ ref?: React.Ref;
+ /** Theme override for the app bar. */
+ theme?: ThemeProp;
+ /** TestID used for testing purposes. */
+ testID?: string;
+};
+
+type AppbarHeadlineProps = {
+ /** Headline and subtitle alignment. */
+ headlineAlignment?: AppbarHeadlineAlignment;
+ /** Trailing actions. */
+ trailingActions?: AppbarTrailingActions;
+ /** Style applied to the headline and subtitle area. */
+ contentStyle?: StyleProp;
+ searchBar?: never;
+} & (
+ | {
+ /** Called when the headline area is pressed. */
+ onHeadlinePress: (event: GestureResponderEvent) => void;
+ /** Props applied to the interactive headline area. */
+ headlinePressableProps?: AppbarHeadlinePressableProps;
+ }
+ | {
+ onHeadlinePress?: never;
+ headlinePressableProps?: never;
+ }
+);
+
+export type AppbarSearchbarProps = Omit<
+ SearchbarProps,
+ 'elevation' | 'mode' | 'showDivider' | 'theme'
+> & {
+ /** Search hint. Material guidance recommends including the word “Search”. */
+ placeholder: string;
+};
+
+type AppbarSearchProps = {
+ /** Visual and layout variant of the app bar. */
+ variant: 'search';
+ /** Props forwarded to the existing Paper Searchbar. */
+ searchBar: AppbarSearchbarProps;
+ /** Exterior trailing actions. */
+ trailingActions?: readonly AppbarStandardTrailingAction[];
+ headline?: never;
+ subtitle?: never;
+ headlineImage?: never;
+ headlineAlignment?: never;
+ headlineProps?: never;
+ subtitleProps?: never;
+ onHeadlinePress?: never;
+ headlinePressableProps?: never;
+ contentStyle?: never;
+};
+
+export type Props = AppbarBaseProps &
+ (
+ | (AppbarHeadlineProps &
+ (
+ | AppbarTextHeadline
+ | AppbarSmallImageHeadline
+ | AppbarFlexibleImageHeadline
+ ))
+ | AppbarSearchProps
+ );
diff --git a/src/components/Appbar/utils.ts b/src/components/Appbar/utils.ts
index 29978636b9..ca9c9087cb 100644
--- a/src/components/Appbar/utils.ts
+++ b/src/components/Appbar/utils.ts
@@ -1,17 +1,11 @@
-import React from 'react';
-import type { ColorValue, StyleProp, ViewStyle } from 'react-native';
-import { StyleSheet } from 'react-native';
+import type { ViewStyle } from 'react-native';
-import { white } from '../../theme/colors';
-import type { InternalTheme, ThemeProp } from '../../types';
+import type { AppbarTrailingAction, AppbarVariant } from './types';
-export type AppbarModes = 'small' | 'medium' | 'large' | 'center-aligned';
-
-export type AppbarChildProps = {
- isLeading?: boolean;
- color: string;
- style?: StyleProp;
-};
+export const APPBAR_ICON_BUTTON_SIZE = 48;
+export const APPBAR_WIDE_ICON_BUTTON_SIZE = 64;
+export const APPBAR_HEADLINE_IMAGE_HEIGHT = 32;
+export const APPBAR_SEARCH_MAX_WIDTH = 720;
const borderStyleProperties = [
'borderRadius',
@@ -30,38 +24,6 @@ const borderStyleProperties = [
'borderCurve',
] satisfies readonly (keyof ViewStyle)[];
-export const getAppbarBackgroundColor = (
- theme: InternalTheme,
- elevated: boolean,
- customBackground?: ColorValue
-) => {
- const { colors } = theme;
- if (customBackground) {
- return customBackground;
- }
-
- if (elevated) {
- return colors.surfaceContainer;
- }
-
- return colors.surface;
-};
-
-export const getAppbarColor = ({
- color,
- isDark,
-}: BaseProps & { color: string }) => {
- if (typeof color !== 'undefined') {
- return color;
- }
-
- if (isDark) {
- return white;
- }
-
- return undefined;
-};
-
export const getAppbarBorders = (style: ViewStyle) => {
let borders: ViewStyle = {};
@@ -76,110 +38,29 @@ export const getAppbarBorders = (style: ViewStyle) => {
return borders;
};
-type BaseProps = {
- isDark: boolean;
-};
-
-type RenderAppbarContentProps = BaseProps & {
- children: React.ReactNode;
- shouldCenterContent?: boolean;
- renderOnly?: (string | boolean)[];
- renderExcept?: string[];
- mode?: AppbarModes;
- theme?: ThemeProp;
-};
-
-export const DEFAULT_APPBAR_HEIGHT = 56;
-const MD3_DEFAULT_APPBAR_HEIGHT = 64;
-
-export const modeAppbarHeight = {
- small: MD3_DEFAULT_APPBAR_HEIGHT,
- medium: 112,
- large: 152,
- 'center-aligned': MD3_DEFAULT_APPBAR_HEIGHT,
-};
-
-export const modeTextVariant = {
- small: 'titleLarge',
- medium: 'headlineSmall',
- large: 'headlineMedium',
- 'center-aligned': 'titleLarge',
-} as const;
-
-export const filterAppbarActions = (
- children: React.ReactNode,
- isLeading = false
+export const getAppbarHeight = (
+ variant: AppbarVariant,
+ hasSubtitle: boolean
) => {
- return React.Children.toArray(children).filter((child) => {
- if (!React.isValidElement(child)) return false;
- return isLeading ? child.props.isLeading : !child.props.isLeading;
- });
-};
-
-export const renderAppbarContent = ({
- children,
- isDark,
- shouldCenterContent = false,
- renderOnly,
- renderExcept,
- mode = 'small',
- theme,
-}: RenderAppbarContentProps) => {
- return React.Children.toArray(children)
- .filter((child) => child != null && typeof child !== 'boolean')
- .filter((child) =>
- // @ts-expect-error: TypeScript complains about the type of type but it doesn't matter
- renderExcept ? !renderExcept.includes(child.type.displayName) : child
- )
- .filter((child) =>
- // @ts-expect-error: TypeScript complains about the type of type but it doesn't matter
- renderOnly ? renderOnly.includes(child.type.displayName) : child
- )
- .map((child, i) => {
- if (
- !React.isValidElement(child) ||
- ![
- 'Appbar.Content',
- 'Appbar.Action',
- 'Appbar.BackAction',
- 'Tooltip',
- ].includes(
- // @ts-expect-error: TypeScript complains about the type of type but it doesn't matter
- child.type.displayName
- )
- ) {
- return child;
- }
+ if (variant === 'search' || variant === 'small') {
+ return 64;
+ }
- const props: {
- color?: string;
- style?: StyleProp;
- mode?: AppbarModes;
- theme?: ThemeProp;
- } = {
- theme,
- color: getAppbarColor({ color: child.props.color, isDark }),
- };
+ if (variant === 'medium-flexible') {
+ return hasSubtitle ? 136 : 112;
+ }
- // @ts-expect-error: TypeScript complains about the type of type but it doesn't matter
- if (child.type.displayName === 'Appbar.Content') {
- props.mode = mode;
- props.style = [
- i === 0 && !shouldCenterContent && styles.v3Spacing,
- shouldCenterContent && styles.centerAlignedContent,
- child.props.style,
- ];
- props.color;
- }
- return React.cloneElement(child, props);
- });
+ return hasSubtitle ? 152 : 120;
};
-const styles = StyleSheet.create({
- centerAlignedContent: {
- alignItems: 'center',
- },
- v3Spacing: {
- marginLeft: 12,
- },
-});
+export const getTrailingActionsWidth = (
+ actions: readonly AppbarTrailingAction[]
+) =>
+ actions.reduce(
+ (width, action) =>
+ width +
+ (action.variant !== 'standard' && action.width === 'wide'
+ ? APPBAR_WIDE_ICON_BUTTON_SIZE
+ : APPBAR_ICON_BUTTON_SIZE),
+ 0
+ );
diff --git a/src/components/Card/CardActions.tsx b/src/components/Card/CardActions.tsx
index 529db00f52..3717069cb8 100644
--- a/src/components/Card/CardActions.tsx
+++ b/src/components/Card/CardActions.tsx
@@ -15,6 +15,10 @@ export type Props = ViewProps & {
theme?: ThemeProp;
};
+const defaultContainerStyle: ViewStyle = {
+ justifyContent: 'flex-end',
+};
+
/**
* A component to show a list of actions inside a Card.
*
@@ -38,11 +42,7 @@ export type Props = ViewProps & {
const CardActions = ({ theme, style, children, ...rest }: Props) => {
useInternalTheme(theme);
- const containerStyle = [
- styles.container,
- { justifyContent: 'flex-end' } satisfies ViewStyle,
- style,
- ];
+ const containerStyle = [styles.container, defaultContainerStyle, style];
return (
diff --git a/src/components/__tests__/Appbar/Appbar.test.tsx b/src/components/__tests__/Appbar/Appbar.test.tsx
index 5bba9563f4..86ca0e7632 100644
--- a/src/components/__tests__/Appbar/Appbar.test.tsx
+++ b/src/components/__tests__/Appbar/Appbar.test.tsx
@@ -1,329 +1,805 @@
-import { describe, expect, it } from '@jest/globals';
+import * as React from 'react';
+import { Dimensions, Text, View } from 'react-native';
+import type { GestureResponderEvent } from 'react-native';
+
+import { describe, expect, it, jest } from '@jest/globals';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { getTheme } from '../../../core/theming';
-import { render, screen } from '../../../test-utils';
-import { tokens } from '../../../theme/tokens';
+import { render, screen, userEvent, waitFor } from '../../../test-utils';
import Appbar from '../../Appbar';
-import {
- getAppbarBackgroundColor,
- getAppbarBorders,
- modeTextVariant,
- renderAppbarContent as utilRenderAppbarContent,
-} from '../../Appbar/utils';
+import type { AppbarVariant } from '../../Appbar';
import Menu from '../../Menu/Menu';
-import Searchbar from '../../Searchbar';
-import Text from '../../Typography/Text';
+import Portal from '../../Portal/Portal';
+import Tooltip from '../../Tooltip/Tooltip';
+
+const testIDPrefix = 'appbar';
+
+const writtenHeadlineVariants: Exclude[] = [
+ 'small',
+ 'medium-flexible',
+ 'large-flexible',
+];
+
+const decorativeHeadlineImage = (
+
+
+
+ Brand words
+
+
+);
+
+describe('Appbar content', () => {
+ it.each([
+ {
+ variant: 'small',
+ headlineVariant: 'titleLarge',
+ headlineLines: 1,
+ subtitleVariant: 'labelMedium',
+ subtitleSpacing: 0,
+ },
+ {
+ variant: 'medium-flexible',
+ headlineVariant: 'headlineMedium',
+ headlineLines: 2,
+ subtitleVariant: 'labelLarge',
+ subtitleSpacing: 4,
+ },
+ {
+ variant: 'large-flexible',
+ headlineVariant: 'displaySmall',
+ headlineLines: 2,
+ subtitleVariant: 'titleMedium',
+ subtitleSpacing: 8,
+ },
+ ] as const)(
+ 'uses the $variant content treatment',
+ async ({
+ variant,
+ headlineVariant,
+ headlineLines,
+ subtitleVariant,
+ subtitleSpacing,
+ }) => {
+ await render(
+
+ );
-// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
-const renderAppbarContent = utilRenderAppbarContent as (
- props: Parameters[0]
-) => { props: any }[];
+ expect(screen.getByTestId('appbar-content-headline-text')).toHaveStyle(
+ getTheme().fonts[headlineVariant]
+ );
+ expect(screen.getByTestId('appbar-content-headline-text')).toHaveProp(
+ 'numberOfLines',
+ headlineLines
+ );
+ expect(screen.getByTestId('appbar-content-subtitle-text')).toHaveStyle({
+ ...getTheme().fonts[subtitleVariant],
+ color: getTheme().colors.onSurfaceVariant,
+ marginTop: subtitleSpacing,
+ });
+ }
+ );
-describe('Appbar', () => {
- it('does not pass any additional props to Searchbar', async () => {
- const tree = (
- await render(
-
-
-
- )
- ).toJSON();
+ it('centers text and balances asymmetric controls for centered and image layouts', async () => {
+ const { rerender } = await render(
+
+ );
- expect(tree).toMatchSnapshot();
- });
+ expect(screen.getByTestId('appbar-content-headline-text')).toHaveStyle({
+ textAlign: 'center',
+ });
+ expect(screen.getByTestId('appbar-content-subtitle-text')).toHaveStyle({
+ textAlign: 'center',
+ });
+ expect(screen.getByTestId('leading-action-container').parent).toHaveStyle({
+ width: 96,
+ });
+ expect(screen.getByTestId('trailing-action-container').parent).toHaveStyle({
+ width: 96,
+ });
- it('passes additional props to AppbarBackAction, AppbarContent and AppbarAction', async () => {
- const tree = (
- await render(
-
- {}} />
-
- {}} />
-
- )
- ).toJSON();
-
- expect(tree).toMatchSnapshot();
+ await rerender(
+ }
+ leadingButton={{
+ type: 'back',
+ testID: 'leading-action',
+ }}
+ trailingActions={[
+ {
+ key: 'more',
+ icon: 'dots-vertical',
+ 'aria-label': 'More options',
+ testID: 'trailing-action',
+ },
+ {
+ key: 'search',
+ icon: 'magnify',
+ 'aria-label': 'Search inbox',
+ },
+ ]}
+ testID={testIDPrefix}
+ />
+ );
+
+ expect(screen.getByTestId('leading-action-container').parent).toHaveStyle({
+ width: 96,
+ });
+ expect(screen.getByTestId('trailing-action-container').parent).toHaveStyle({
+ width: 96,
+ });
+ expect(
+ screen.getByTestId('brand-mark', { includeHiddenElements: true })
+ ).toBeOnTheScreen();
});
-});
-describe('renderAppbarContent', () => {
- const children = [
- {}} key={0} />,
- ,
- {}} key={2} />,
- {}} key={3} />,
- ];
-
- it('should render all children types if renderOnly is not specified', () => {
- const result = renderAppbarContent({
- children,
- isDark: false,
+ it('adjusts leading headline spacing when a leading button is present', async () => {
+ const { rerender } = await render(
+
+ );
+
+ expect(screen.getByTestId('appbar-content')).toHaveStyle({
+ marginStart: 12,
});
- expect(result).toHaveLength(4);
+ await rerender(
+
+ );
+
+ expect(screen.getByTestId('appbar-content')).toHaveStyle({
+ marginStart: 4,
+ });
});
+});
+
+describe('Appbar surface', () => {
+ it('uses scroll container colors unless a custom background is supplied', async () => {
+ const customBackground = 'rebeccapurple';
+ const { rerender } = await render(
+
+ );
- it('should render all children types except specified in renderExcept', () => {
- const result = renderAppbarContent({
- children: [
- ...children,
- ,
- ],
- isDark: false,
- renderExcept: [
- 'Appbar',
- 'Appbar.Header',
- 'Appbar.BackAction',
- 'Appbar.Content',
- ],
+ expect(screen.getByTestId('appbar-root-layer')).toHaveStyle({
+ backgroundColor: getTheme().colors.surface,
});
- expect(result).toHaveLength(3);
- });
+ await rerender(
+
+ );
- it('should render only children types specifed in renderOnly', () => {
- const result = renderAppbarContent({
- children,
- isDark: false,
- renderOnly: ['Appbar.Action'],
+ expect(screen.getByTestId('appbar-root-layer')).toHaveStyle({
+ backgroundColor: getTheme().colors.surfaceContainer,
});
- expect(result).toHaveLength(2);
- });
+ await rerender(
+
+ );
- it('should render AppbarContent with correct mode', () => {
- const result = renderAppbarContent({
- children,
- isDark: false,
- renderOnly: ['Appbar.Content'],
- mode: 'large',
+ expect(screen.getByTestId('appbar-root-layer')).toHaveStyle({
+ backgroundColor: customBackground,
});
+ });
- // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion.
- expect(result[0].props.mode).toBe('large');
+ it('applies border clipping and resolves safe-area overrides on the surface', async () => {
+ await render(
+
+
+
+ );
+
+ expect(screen.getByTestId('appbar-root-layer')).toHaveStyle({
+ borderBottomLeftRadius: 16,
+ borderBottomRightRadius: 16,
+ paddingTop: 20,
+ paddingHorizontal: 12,
+ });
});
+});
+
+describe('Appbar actions', () => {
+ it('maps leading, trailing, and custom action colors', async () => {
+ await render(
+ (
+
+ ),
+ 'aria-label': 'Navigation',
+ }}
+ trailingActions={[
+ {
+ key: 'default',
+ icon: ({ color }) => (
+
+ ),
+ 'aria-label': 'Default action',
+ },
+ {
+ key: 'custom',
+ icon: ({ color }) => (
+
+ ),
+ 'aria-label': 'Custom action',
+ color: 'rebeccapurple',
+ },
+ ]}
+ testID={testIDPrefix}
+ />
+ );
- it('should render centered AppbarContent', () => {
- const result = renderAppbarContent({
- children,
- isDark: false,
- renderOnly: ['Appbar.Content'],
- mode: 'center-aligned',
- shouldCenterContent: true,
+ expect(screen.getByTestId('leading-icon-color')).toHaveStyle({
+ backgroundColor: getTheme().colors.onSurface,
});
+ expect(screen.getByTestId('trailing-icon-color')).toHaveStyle({
+ backgroundColor: getTheme().colors.onSurfaceVariant,
+ });
+ expect(screen.getByTestId('custom-icon-color')).toHaveStyle({
+ backgroundColor: 'rebeccapurple',
+ });
+ });
- const centerAlignedContent = {
- alignItems: 'center',
- };
+ it.each([
+ { variant: 'filled', color: getTheme().colors.primary },
+ { variant: 'tonal', color: getTheme().colors.secondaryContainer },
+ ] as const)(
+ 'maps $variant actions to their selected container color',
+ async ({ variant, color }) => {
+ await render(
+
+ );
- // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion.
- expect(result[0].props.style).toEqual(
- expect.arrayContaining([expect.objectContaining(centerAlignedContent)])
+ expect(screen.getByTestId('expressive-action-container')).toHaveStyle({
+ backgroundColor: color,
+ });
+ expect(screen.getByTestId('expressive-action-container')).toHaveStyle({
+ width: 56,
+ });
+ }
+ );
+
+ it('decorates an action with a working tooltip without losing its press handler', async () => {
+ const onPress = jest.fn<(event: GestureResponderEvent) => void>();
+
+ await render(
+
+ (
+ {button}
+ ),
+ },
+ ]}
+ />
+
);
+
+ const action = screen.getByRole('button', { name: 'Print' });
+
+ await userEvent.press(action);
+ expect(onPress).toHaveBeenCalledTimes(1);
+
+ await userEvent.longPress(action);
+ expect(await screen.findByText('Print shortcut')).toBeOnTheScreen();
});
- it('should render AppbarContent with correct spacings', () => {
- const renderResult = (withAppbarBackAction = false) =>
- renderAppbarContent({
- children,
- isDark: false,
- renderOnly: [
- 'Appbar.Content',
- withAppbarBackAction && 'Appbar.BackAction',
- ],
+ it('decorates an action with a menu anchored to the resolved button', async () => {
+ const dimensionsSpy = jest.spyOn(Dimensions, 'get').mockReturnValue({
+ width: 400,
+ height: 800,
+ scale: 2,
+ fontScale: 2,
+ });
+ const measureSpy = jest
+ .spyOn(View.prototype, 'measureInWindow')
+ .mockImplementation((callback) => callback(100, 100, 80, 32));
+ const MenuAppbar = () => {
+ const [visible, setVisible] = React.useState(false);
+
+ return (
+
+ setVisible(true),
+ decorate: (button) => (
+
+ ),
+ },
+ ]}
+ />
+
+ );
+ };
+
+ await render();
+
+ await userEvent.press(screen.getByRole('button', { name: 'More options' }));
+
+ await waitFor(() => {
+ expect(screen.getByText('Undo')).toBeOnTheScreen();
+ expect(screen.getByTestId('menu-view')).toHaveStyle({
+ position: 'absolute',
+ left: 100,
+ top: 132,
});
+ });
+ expect(measureSpy).toHaveBeenCalled();
- const v3Spacing = {
- marginLeft: 12,
+ measureSpy.mockRestore();
+ dimensionsSpy.mockRestore();
+ });
+});
+
+describe('Appbar search', () => {
+ it('uses the placeholder as the searchbox label unless an explicit label is supplied', async () => {
+ const { rerender } = await render(
+
+ );
+
+ expect(
+ screen.getByRole('searchbox', { name: 'Search messages' })
+ ).toBeOnTheScreen();
+ expect(screen.getByTestId('appbar-search-container')).toHaveStyle({
+ backgroundColor: getTheme().colors.surfaceContainer,
+ });
+
+ await rerender(
+
+ );
+
+ expect(
+ screen.getByRole('searchbox', { name: 'Message search' })
+ ).toBeOnTheScreen();
+ });
+
+ it('configures the search field, forwards its behavior, and constrains its width', async () => {
+ const onChangeText = jest.fn();
+ const SearchAppbar = () => {
+ const [value, setValue] = React.useState('');
+
+ return (
+ {
+ setValue(nextValue);
+ onChangeText(nextValue);
+ },
+ testID: 'message-search',
+ }}
+ trailingActions={[
+ {
+ key: 'more',
+ icon: 'dots-vertical',
+ 'aria-label': 'More options',
+ },
+ ]}
+ testID={testIDPrefix}
+ />
+ );
};
- // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion.
- expect(renderResult()[0].props.style).toEqual(
- expect.arrayContaining([expect.objectContaining(v3Spacing)])
+ await render();
+
+ const searchbox = screen.getByRole('searchbox', {
+ name: 'Search messages',
+ });
+ expect(searchbox).toHaveStyle({
+ color: getTheme().colors.onSurface,
+ });
+ expect(searchbox).toHaveProp(
+ 'placeholderTextColor',
+ getTheme().colors.onSurfaceVariant
);
+ expect(screen.getByTestId('message-search-container')).toHaveStyle({
+ backgroundColor: getTheme().colors.surfaceContainerHighest,
+ });
+ expect(
+ screen.getByRole('button', { name: 'Navigate back' })
+ ).toBeOnTheScreen();
+ expect(
+ screen.getByRole('button', { name: 'More options' })
+ ).toBeOnTheScreen();
+
+ await userEvent.type(searchbox, 'draft');
+ expect(onChangeText).toHaveBeenLastCalledWith('draft');
+ expect(searchbox).toHaveProp('value', 'draft');
+
+ expect(screen.getByTestId('message-search-container')).toHaveStyle({
+ width: '100%',
+ });
+ expect(screen.getByTestId('appbar-search-width-limiter')).toHaveStyle({
+ width: '100%',
+ maxWidth: 720,
+ });
});
+});
- it('Is recognized as a heading when no onPress callback has been passed', async () => {
+describe('Appbar accessibility', () => {
+ it.each(writtenHeadlineVariants)(
+ 'exposes a written %s headline as a heading',
+ async (variant) => {
+ await render();
+
+ expect(screen.getByRole('heading', { name: 'Inbox' })).toBeOnTheScreen();
+ }
+ );
+
+ it('hides a small headline image behind one named heading', async () => {
await render(
-
-
-
-
-
+
);
- expect(screen.getByRole('heading')).toBeOnTheScreen();
+ expect(screen.getAllByRole('heading')).toHaveLength(1);
+ expect(screen.getByRole('heading', { name: 'Inbox' })).toBeOnTheScreen();
+ expect(screen.queryByRole('img')).not.toBeOnTheScreen();
+ expect(screen.queryByText('Brand words')).not.toBeOnTheScreen();
});
- it('is recognized as a button when onPress callback has been passed', async () => {
+
+ it.each(['medium-flexible', 'large-flexible'] as const)(
+ 'does not expose duplicate content for a %s headline image',
+ async (variant) => {
+ await render(
+
+ );
+
+ expect(screen.getAllByRole('heading', { name: 'Inbox' })).toHaveLength(1);
+ expect(screen.queryByRole('img')).not.toBeOnTheScreen();
+ expect(screen.queryByLabelText('Brand artwork')).not.toBeOnTheScreen();
+ }
+ );
+
+ it('exposes a pressable written headline as a named button and heading', async () => {
+ const onHeadlinePress = jest.fn();
await render(
-
-
- {}} />
-
-
+
);
- expect(screen.getByRole('button')).toBeEnabled();
- expect(screen.queryByRole('heading')).not.toBeOnTheScreen();
+ expect(screen.getByRole('button', { name: 'Inbox' })).toBeOnTheScreen();
+ expect(screen.getByRole('heading', { name: 'Inbox' })).toBeOnTheScreen();
});
- it('is recognized as a disabled button when onPress and disabled is passed', async () => {
+
+ it('does not invoke a disabled headline action', async () => {
+ const onHeadlinePress = jest.fn();
await render(
-
-
- {}} disabled />
-
-
+
);
- expect(screen.getByRole('button')).toBeDisabled();
- expect(screen.queryByRole('heading')).not.toBeOnTheScreen();
+ const headlineButton = screen.getByRole('button', {
+ name: 'Inbox',
+ disabled: true,
+ });
+ await userEvent.press(headlineButton);
+
+ expect(onHeadlinePress).not.toHaveBeenCalled();
});
-});
-describe('AppbarAction', () => {
- it('should be rendered with default theme color', async () => {
+ it('names a pressable small headline image from its fallback headline', async () => {
await render(
-
-
-
- );
- // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion.
- const appbarActionIcon = screen.getByTestId('cross-fade-icon-current').props
- .children;
- // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion.
- expect(appbarActionIcon.props.color).toBe(
- getTheme().colors.onSurfaceVariant
+ {}}
+ testID={testIDPrefix}
+ />
);
+
+ expect(screen.getByRole('button', { name: 'Inbox' })).toBeOnTheScreen();
});
- it('should be rendered with specific theme color if is leading', async () => {
+ it('forwards custom headline button accessibility props', async () => {
await render(
-
-
-
+ {}}
+ headlinePressableProps={{
+ accessibilityHint: 'Opens the inbox menu',
+ accessibilityLabel: 'Inbox options',
+ accessibilityState: { busy: true, expanded: true },
+ }}
+ testID={testIDPrefix}
+ />
);
- // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion.
- const appbarActionIcon = screen.getByTestId('cross-fade-icon-current').props
- .children;
- // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion.
- expect(appbarActionIcon.props.color).toBe(getTheme().colors.onSurface);
+
+ const titleButton = screen.getByRole('button', {
+ name: 'Inbox options',
+ busy: true,
+ expanded: true,
+ });
+
+ expect(titleButton).toHaveProp('accessibilityHint', 'Opens the inbox menu');
});
- it('should be rendered with custom color', async () => {
+ it('uses default and custom labels for back buttons', async () => {
await render(
-
-
-
+
+
+
+
);
- // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion.
- const appbarActionIcon = screen.getByTestId('cross-fade-icon-current').props
- .children;
- // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion.
- expect(appbarActionIcon.props.color).toBe('purple');
+
+ expect(screen.getByRole('button', { name: 'Back' })).toBeOnTheScreen();
+ expect(
+ screen.getByRole('button', { name: 'Return to inbox' })
+ ).toBeOnTheScreen();
});
- it('should render AppbarBackAction with custom color', async () => {
+ it('preserves trailing action labels, hints, disabled state, and busy state', async () => {
await render(
-
-
-
+
);
- // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion.
- const appbarBackActionIcon = screen.getByTestId('cross-fade-icon-current')
- .props.children;
- // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion.
- expect(appbarBackActionIcon.props.color).toBe('purple');
+
+ const trailingAction = screen.getByRole('button', {
+ name: 'Save message',
+ busy: true,
+ disabled: true,
+ });
+
+ expect(trailingAction).toHaveProp(
+ 'accessibilityHint',
+ 'Saves the current draft'
+ );
+ expect(trailingAction).toBeDisabled();
+ expect(trailingAction).toBeBusy();
});
-});
-describe('AppbarContent', () => {
- (['small', 'medium', 'large', 'center-aligned'] as const).forEach((mode) =>
- it(`should render text component with appropriate variant for ${mode} mode`, async () => {
+ it.each(['small', 'medium-flexible'] as const)(
+ 'hides decorative descendants of a %s headline image',
+ async (variant) => {
await render(
-
-
-
+ variant === 'small' ? (
+
+ ) : (
+
+ )
);
- expect(screen.getByTestId('appbar-content-title-text')).toHaveStyle(
- getTheme().fonts[modeTextVariant[mode]]
+ const imageContainer = screen.getByTestId(
+ 'appbar-content-headline-image',
+ { includeHiddenElements: true }
);
- })
+
+ expect(imageContainer).toHaveProp('aria-hidden', true);
+ expect(
+ screen.queryByRole('heading', { name: 'Brand words' })
+ ).not.toBeOnTheScreen();
+ }
);
- it('should render component passed to title', async () => {
+ it('exposes the leading button, headline, and trailing actions in source order', async () => {
await render(
-
-
- Title
-
- }
- />
-
+
);
- expect(screen.getByText('Title')).toBeOnTheScreen();
+ expect(screen.getAllByRole(/button|heading/)).toEqual([
+ screen.getByRole('button', { name: 'Navigate back' }),
+ screen.getByRole('heading', { name: 'Inbox' }),
+ screen.getByRole('button', { name: 'Search inbox' }),
+ screen.getByRole('button', { name: 'More options' }),
+ ]);
});
-});
-describe('getAppbarColors', () => {
- const elevated = true;
- const customBackground = 'aquamarine';
-
- it('should return custom color no matter what is the theme version', () => {
- expect(
- getAppbarBackgroundColor(getTheme(), elevated, customBackground)
- ).toBe(customBackground);
- });
-
- it('returns the light surface container color for an elevated appbar', () => {
- expect(getAppbarBackgroundColor(getTheme(), elevated)).toBe(
- tokens.md.ref.palette.neutral94
+ it('does not remount a surviving trailing action when its configuration changes', async () => {
+ const { rerender } = await render(
+
);
- });
+ const survivingTrailingAction = screen.getByRole('button', {
+ name: 'Keep action',
+ });
- it('returns the dark surface container color for an elevated appbar', () => {
- expect(getAppbarBackgroundColor(getTheme(true), elevated)).toBe(
- tokens.md.ref.palette.neutral12
+ await rerender(
+
);
- });
-});
-describe('getAppbarBorders', () => {
- const borderStyles = {
- borderRadius: 1,
- borderBottomEndRadius: 2,
- borderBottomStartRadius: 3,
- borderEndEndRadius: 4,
- borderEndStartRadius: 5,
- borderStartEndRadius: 6,
- borderStartStartRadius: 7,
- borderTopEndRadius: 8,
- borderTopStartRadius: 9,
- borderTopLeftRadius: 10,
- borderTopRightRadius: 11,
- borderBottomRightRadius: 12,
- borderBottomLeftRadius: 13,
- borderCurve: 'continuous' as const,
- };
-
- it('returns every border style and excludes unrelated styles', () => {
- expect(getAppbarBorders({ ...borderStyles, height: 60, top: 13 })).toEqual(
- borderStyles
+ expect(screen.getByRole('button', { name: 'Keep action' })).toBe(
+ survivingTrailingAction
);
});
-
- it('returns an empty object when no border styles are passed', () => {
- expect(getAppbarBorders({ height: 60, top: 13 })).toEqual({});
- });
});
diff --git a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap
deleted file mode 100644
index 882652f92d..0000000000
--- a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap
+++ /dev/null
@@ -1,973 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Appbar does not pass any additional props to Searchbar 1`] = `
-
-
-
-
-
-
-
-
- magnify
-
-
-
-
-
-
-
-
-
-
- close
-
-
-
-
-
-
-
-`;
-
-exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and AppbarAction 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Examples
-
-
-
-
-
-
-
-
- menu
-
-
-
-
-
-
-
-`;
diff --git a/src/components/__tests__/Appbar/utils.test.ts b/src/components/__tests__/Appbar/utils.test.ts
new file mode 100644
index 0000000000..5e9efd23a7
--- /dev/null
+++ b/src/components/__tests__/Appbar/utils.test.ts
@@ -0,0 +1,87 @@
+import { describe, expect, it } from '@jest/globals';
+
+import {
+ getAppbarBorders,
+ getAppbarHeight,
+ getTrailingActionsWidth,
+} from '../../Appbar/utils';
+
+describe('getAppbarBorders', () => {
+ const borderStyles = {
+ borderRadius: 0,
+ borderBottomEndRadius: 2,
+ borderBottomStartRadius: 3,
+ borderEndEndRadius: 4,
+ borderEndStartRadius: 5,
+ borderStartEndRadius: 6,
+ borderStartStartRadius: 7,
+ borderTopEndRadius: 8,
+ borderTopStartRadius: 9,
+ borderTopLeftRadius: 10,
+ borderTopRightRadius: 11,
+ borderBottomRightRadius: 12,
+ borderBottomLeftRadius: 13,
+ borderCurve: 'continuous' as const,
+ };
+
+ it('returns every border style and excludes unrelated styles', () => {
+ expect(getAppbarBorders({ ...borderStyles, height: 60, top: 13 })).toEqual(
+ borderStyles
+ );
+ });
+
+ it('returns an empty object when no border styles are passed', () => {
+ expect(getAppbarBorders({ height: 60, top: 13 })).toEqual({});
+ });
+});
+
+describe('getAppbarHeight', () => {
+ it.each([
+ { variant: 'search', subtitle: false, height: 64 },
+ { variant: 'search', subtitle: true, height: 64 },
+ { variant: 'small', subtitle: false, height: 64 },
+ { variant: 'small', subtitle: true, height: 64 },
+ { variant: 'medium-flexible', subtitle: false, height: 112 },
+ { variant: 'medium-flexible', subtitle: true, height: 136 },
+ { variant: 'large-flexible', subtitle: false, height: 120 },
+ { variant: 'large-flexible', subtitle: true, height: 152 },
+ ] as const)(
+ 'returns $height for $variant with subtitle=$subtitle',
+ ({ variant, subtitle, height }) => {
+ expect(getAppbarHeight(variant, subtitle)).toBe(height);
+ }
+ );
+});
+
+describe('getTrailingActionsWidth', () => {
+ it('accounts for standard, filled, and wide filled actions', () => {
+ expect(getTrailingActionsWidth([])).toBe(0);
+ expect(
+ getTrailingActionsWidth([
+ { key: 'first', icon: 'star', 'aria-label': 'First' },
+ { key: 'second', icon: 'heart', 'aria-label': 'Second' },
+ ])
+ ).toBe(96);
+ expect(
+ getTrailingActionsWidth([
+ {
+ key: 'filled',
+ icon: 'star',
+ 'aria-label': 'Filled',
+ variant: 'filled',
+ },
+ ])
+ ).toBe(48);
+ expect(
+ getTrailingActionsWidth([
+ {
+ key: 'wide',
+ icon: 'star',
+ 'aria-label': 'Wide',
+ variant: 'tonal',
+ width: 'wide',
+ },
+ ])
+ ).toBe(64);
+ });
+});
diff --git a/src/index.tsx b/src/index.tsx
index 8863e2fa20..00002929c4 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -55,11 +55,21 @@ export { default as Text, customText } from './components/Typography/Text';
// Types
export type { Props as ActivityIndicatorProps } from './components/ActivityIndicator';
-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';
-export type { Props as AppbarContentProps } from './components/Appbar/AppbarContent';
-export type { Props as AppbarHeaderProps } from './components/Appbar/AppbarHeader';
+export type {
+ Props as AppbarProps,
+ AppbarActionDecorator,
+ AppbarFilledTrailingAction,
+ AppbarHeadlineAlignment,
+ AppbarHeadlinePressableProps,
+ AppbarHeadlineTextProps,
+ AppbarLeadingButton,
+ AppbarSearchbarProps,
+ AppbarStandardTrailingAction,
+ AppbarTextProps,
+ AppbarTrailingAction,
+ AppbarTrailingActions,
+ AppbarVariant,
+} from './components/Appbar';
export type { Props as AvatarIconProps } from './components/Avatar/AvatarIcon';
export type { Props as AvatarImageProps } from './components/Avatar/AvatarImage';
export type { Props as AvatarTextProps } from './components/Avatar/AvatarText';