diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md
index e51de006aa..83d46aaa0c 100644
--- a/docs/6.x/docs/guides/migration.md
+++ b/docs/6.x/docs/guides/migration.md
@@ -10,7 +10,7 @@ React Native Paper 6 uses [Reanimated](https://docs.swmansion.com/react-native-r
The following props now accept animated styles returned from `useAnimatedStyle`. They no longer accept `Animated.Value` or `Animated.AnimatedInterpolation` where these were previously supported:
-- `Appbar.Action` and `Appbar.BackAction`: `style`
+- `Appbar`: `style`
- `Badge`: `style`
- `Banner`: `style`
- `Button`: `style`
@@ -72,8 +72,6 @@ You can use the component's color prop where available, or override the correspo
Hardcoded default test IDs have been removed for the components listed below:
-- `Appbar.Content`: `appbar-content`
-- `Appbar.Header`: `appbar-header`
- `BottomNavigation`: `bottom-navigation`
- `BottomNavigation.Bar`: `bottom-navigation-bar`
- `Button`: `button`
@@ -110,9 +108,62 @@ Some components now accept explicit `testID` props for their interactable elemen
### 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 big refatctor, which drops the previously used compound component approach.
-The `style.elevation` property is no longer supported. Use the `elevated` prop to control Appbar elevation.
+#### 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: () => {},
+ },
+ ]}
+ />
+);
+```
+
+`Appbar`'s props are a single flat type shared by every `variant`. Passing a prop to a variant it doesn't apply to doesn't raise a type error — each prop's own JSDoc states which variant(s) it's usable for:
+
+- Usable only for `small`, `medium-flexible`, and `large-flexible`: `headline`, `subtitle`, `headlineProps`, `subtitleProps`, `headlineImage`, `headlineAlignment`, `contentStyle`, `onHeadlinePress`, `headlinePressableProps`. Omitting `headline` logs a development warning.
+- Usable only for `search`: `searchBar`. Omitting it logs a development warning.
+- Usable for every variant, including `search`: `trailingActions`, using the same shape — a standard array, or a single filled/tonal action — everywhere, though a filled/tonal action isn't intended for `search`.
+
+#### 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.
+
+#### Test IDs
+
+`Appbar` no longer derives internal, implementation-only test IDs (e.g. for its surface, content, or search layout wrappers) from the `testID` prop. Only the `testID` prop itself is set on the root element; the `Searchbar` rendered by the `search` variant accepts its own `testID` through `searchBar.testID`.
### 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/component-docs.config.ts b/docs/component-docs.config.ts
index 2cf106849c..f17a768626 100644
--- a/docs/component-docs.config.ts
+++ b/docs/component-docs.config.ts
@@ -33,11 +33,11 @@ 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',
+ // The default export is the memoized `MemoizedAppbar`, but the JSDoc
+ // lives on the inner `Appbar` declaration — same pattern as `Portal`
+ // and `Typography/Text` below.
+ component: 'Appbar',
},
Avatar: {
AvatarIcon: 'Avatar/AvatarIcon',
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 b7c59d424d..d6f6b3affe 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 118114da27..41d09ce0ee 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 28ea39426a..4cd2ad471b 100644
--- a/src/components/Appbar/Appbar.tsx
+++ b/src/components/Appbar/Appbar.tsx
@@ -1,338 +1,570 @@
import * as React from 'react';
import { StyleSheet, View } from 'react-native';
-import type { ColorValue, StyleProp, ViewProps, ViewStyle } from 'react-native';
+import type {
+ ColorValue,
+ GestureResponderEvent,
+ StyleProp,
+ ViewProps,
+ ViewStyle,
+} from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import AppbarButton from './AppbarButton';
import AppbarContent from './AppbarContent';
+import type {
+ AppbarBaseProps,
+ AppbarHeadlineAlignment,
+ AppbarHeadlinePressableProps,
+ AppbarHeadlineTextProps,
+ AppbarSearchbarProps,
+ AppbarTextProps,
+ AppbarTrailingActions,
+ AppbarVariant,
+} 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 '../../theme/types';
+import Searchbar from '../Searchbar';
import Surface from '../Surface';
-const APPBAR_HORIZONTAL_PADDING = 4;
-
-export type AppbarStyle = Omit;
+const EMPTY_TRAILING_ACTIONS = [] as const;
-export type Props = Omit, 'style'> & {
+export type Props = AppbarBaseProps & {
+ /** Visual and layout variant of the app bar. */
+ variant: AppbarVariant;
/**
- * Whether the background color is a dark color. A dark appbar will render light text and vice-versa.
+ * Written headline displayed by the app bar. Usable for the `small`,
+ * `medium-flexible`, and `large-flexible` variants — it is the accessible
+ * heading for those variants (including when `headlineImage` visually
+ * replaces it on `small`) and omitting it logs a development warning.
*/
- dark?: boolean;
+ headline?: string;
/**
- * Content of the `Appbar`.
+ * Optional supporting text displayed below the headline. Usable for the
+ * `small`, `medium-flexible`, and `large-flexible` variants.
*/
- children: React.ReactNode;
+ subtitle?: string;
/**
- * @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.
+ * Props applied to the headline heading. Usable for the `small`,
+ * `medium-flexible`, and `large-flexible` variants.
*/
- mode?: 'small' | 'medium' | 'large' | 'center-aligned';
+ headlineProps?: AppbarHeadlineTextProps;
/**
- * @supported Available in v5.x with theme version 3
- * Whether Appbar background should have the elevation along with primary color pigment.
+ * Props applied to the subtitle text. Usable for the `small`,
+ * `medium-flexible`, and `large-flexible` variants.
*/
- elevated?: boolean;
+ subtitleProps?: AppbarTextProps;
/**
- * 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.
+ * Image or logo displayed in the app bar. It should fit within 32dp
+ * height. Usable for the `small`, `medium-flexible`, and `large-flexible`
+ * variants. On `small` it replaces the visible headline text (`headline`
+ * is still required for accessibility labeling). On `medium-flexible` and
+ * `large-flexible` it is shown alongside the written headline and
+ * subtitle.
*/
- safeAreaInsets?: {
- bottom?: number;
- top?: number;
- left?: number;
- right?: number;
- };
+ headlineImage?: React.ReactElement;
+ /**
+ * Headline and subtitle alignment. Usable for the `small`,
+ * `medium-flexible`, and `large-flexible` variants.
+ */
+ headlineAlignment?: AppbarHeadlineAlignment;
+ /**
+ * Style applied to the headline and subtitle area. Usable for the
+ * `small`, `medium-flexible`, and `large-flexible` variants.
+ */
+ contentStyle?: StyleProp;
+ /**
+ * Called when the headline area is pressed. Usable for the `small`,
+ * `medium-flexible`, and `large-flexible` variants, independently of
+ * `headlinePressableProps`.
+ */
+ onHeadlinePress?: (event: GestureResponderEvent) => void;
+ /**
+ * Props applied to the interactive headline area. Usable for the `small`,
+ * `medium-flexible`, and `large-flexible` variants; inert unless
+ * `onHeadlinePress` is also set.
+ */
+ headlinePressableProps?: AppbarHeadlinePressableProps;
/**
- * @optional
+ * Trailing actions. Usable for every variant, including `search`. A
+ * single filled or tonal action isn't intended for `search`, but this
+ * isn't enforced at the type level.
*/
- theme?: ThemeProp;
- style?: StyleProp;
+ trailingActions?: AppbarTrailingActions;
+ /**
+ * Props forwarded to the existing Paper Searchbar. Usable only for the
+ * `search` variant; omitting it logs a development warning.
+ */
+ searchBar?: AppbarSearchbarProps;
};
/**
- * 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.
+ * 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"`.
+ *
+ * `Appbar`'s props are a single flat type: every prop is available on every
+ * `variant`, and each prop's own JSDoc states which variant(s) it's usable
+ * for (for example, `headline` and `headlineImage` are usable for `small`,
+ * `medium-flexible`, and `large-flexible`, while `searchBar` is usable only
+ * for `search`). Passing a prop to a variant it doesn't apply to doesn't
+ * raise a type error; it's either silently ignored by that variant's render
+ * logic, or — for a missing `headline` on a non-search variant, or a
+ * missing `searchBar` on `search` — logged as a development warning.
+ *
+ * ## 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 backgroundColor = getAppbarBackgroundColor(
- theme,
- elevated,
- customBackground
- );
+ if (variant !== 'search' && !headline) {
+ console.warn(
+ `Appbar variant "${variant}" is missing a headline, so it has no accessible heading`
+ );
+ }
+
+ if (variant === 'search' && !searchBar) {
+ console.warn(
+ 'Appbar variant "search" is missing a searchBar, so its body will render blank'
+ );
+ }
- const borderStyles = getAppbarBorders(restStyle);
+ const detectedInsets = useSafeAreaInsets();
+ const { customBackground, restStyle, borderRadius } = React.useMemo(() => {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ const resolvedStyle = (StyleSheet.flatten(style) || {}) as ViewStyle & {
+ backgroundColor?: ColorValue;
+ };
+ const { backgroundColor, ...remainingStyle } = resolvedStyle;
- const isMode = (modeToCompare: AppbarModes) => {
- return mode === modeToCompare;
- };
+ return {
+ customBackground: backgroundColor,
+ restStyle: remainingStyle,
+ borderRadius: 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 },
+ }),
+ [theme, searchBackgroundColor]
+ );
+ const {
+ accessibilityLabel: _accessibilityLabel,
+ accessibilityRole: _accessibilityRole,
+ accessible: _accessible,
+ 'aria-label': _ariaLabel,
+ role: _role,
+ ...viewProps
+ } = rest as ViewProps;
- const isDark = typeof dark === 'boolean' ? dark : false;
+ const renderLeadingButton = () =>
+ leadingButton ? (
+
+ ) : null;
- const isCenterAlignedMode = isMode('center-aligned');
+ const renderTrailingActions = () =>
+ trailingActions.map((action) => (
+
+ ));
- let shouldCenterContent = false;
- let shouldAddLeftSpacing = false;
- let shouldAddRightSpacing = false;
- if (isCenterAlignedMode) {
- let hasAppbarContent = false;
- let leftItemsCount = 0;
- let rightItemsCount = 0;
+ const renderSearchAppbar = () => {
+ if (!searchBar) {
+ return null;
+ }
- React.Children.forEach(children, (child) => {
- if (React.isValidElement(child)) {
- const isLeading = child.props.isLeading === true;
+ const {
+ inputStyle: _searchInputStyle,
+ style: _searchStyle,
+ testID: searchTestID,
+ ...searchProps
+ } = searchBar;
- if (child.type === AppbarContent) {
- hasAppbarContent = true;
- } else if (isLeading || !hasAppbarContent) {
- leftItemsCount++;
- } else {
- rightItemsCount++;
+ return (
+
+ {renderLeadingButton()}
+
+
+
+
+
+ {renderTrailingActions()}
+
+ );
+ };
+
+ const contentProps =
+ variant !== 'search'
+ ? {
+ alignment: headlineAlignment,
+ contentStyle,
+ headline,
+ headlineImage,
+ headlinePressableProps,
+ headlineProps,
+ onHeadlinePress,
+ subtitle,
+ subtitleProps,
+ theme,
+ variant,
}
- }
- });
+ : null;
- shouldCenterContent =
- hasAppbarContent && leftItemsCount < 2 && rightItemsCount < 3;
- shouldAddLeftSpacing = shouldCenterContent && leftItemsCount === 0;
- shouldAddRightSpacing = shouldCenterContent && rightItemsCount === 0;
- }
+ const renderFlexibleHeadlineImage = () =>
+ headlineImage ? (
+
+ {headlineImage}
+
+ ) : null;
+
+ const renderSmallAppbar = () => {
+ if (!contentProps) {
+ return null;
+ }
+
+ if (centered || headlineImage) {
+ return (
+
+ {renderLeadingButton()}
+
+
+ {renderTrailingActions()}
+
+
+ );
+ }
+
+ return (
+
+ {renderLeadingButton()}
+
+ {renderTrailingActions()}
+
+ );
+ };
- const spacingStyle = styles.v3Spacing;
+ const renderFlexibleAppbar = () => {
+ 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,
+ 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',
},
- centerAlignedContainer: {
- paddingTop: 0,
+ headlineLeading: {
+ marginStart: 12,
+ },
+ 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 };
+export type { Props as AppbarProps };
diff --git a/src/components/Appbar/AppbarAction.tsx b/src/components/Appbar/AppbarAction.tsx
deleted file mode 100644
index 1c358397c9..0000000000
--- a/src/components/Appbar/AppbarAction.tsx
+++ /dev/null
@@ -1,114 +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 '../../theme/types';
-import type { IconSource } from '../Icon';
-import IconButton from '../IconButton/IconButton';
-import type { Props as IconButtonProps } from '../IconButton/IconButton';
-
-export type Props = React.PropsWithoutRef & {
- /**
- * 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 4f23dfa542..0000000000
--- a/src/components/Appbar/AppbarBackAction.tsx
+++ /dev/null
@@ -1,77 +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 AppbarAction from './AppbarAction';
-import type { Props as AppbarActionProps } from './AppbarAction';
-import AppbarBackIcon from './AppbarBackIcon';
-
-export type Props = Omit, '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..9d418c9466
--- /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 '../../theme/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 37c3b28e2c..c28d221af1 100644
--- a/src/components/Appbar/AppbarContent.tsx
+++ b/src/components/Appbar/AppbarContent.tsx
@@ -1,191 +1,203 @@
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 { ThemeProp } from '../../theme/types';
+import type {
+ AppbarProps,
+ AppbarHeadlineAlignment,
+ AppbarHeadlineVariant,
+} from './types';
+import { APPBAR_HEADLINE_IMAGE_HEIGHT } from './utils';
+import type { Theme, TypescaleKey } from '../../theme/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;
};
-type TitleElement = { title: React.ReactNode; titleStyle?: never };
+const headlineVariants: Record = {
+ small: 'titleLarge',
+ 'medium-flexible': 'headlineMedium',
+ 'large-flexible': 'displaySmall',
+};
-export type Props = Omit, 'children'> & {
- // 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,
+ theme,
+ variant,
style,
- titleRef,
- titleStyle,
- title,
- titleMaxFontSizeMultiplier,
- mode = 'small',
- theme: themeOverrides,
- testID,
- ...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) {
+ 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,
+ },
+ flexibleContainer: {
+ flex: 0,
+ flexBasis: 'auto',
+ paddingHorizontal: 16,
+ paddingTop: 8,
+ paddingBottom: 12,
},
- v3DefaultContainer: {
- paddingHorizontal: 0,
+ centeredContainer: {
+ alignItems: 'center',
},
- v3MediumContainer: {
- paddingHorizontal: 0,
- justifyContent: 'flex-end',
- paddingBottom: 24,
+ text: {
+ alignSelf: 'stretch',
},
- v3LargeContainer: {
- paddingHorizontal: 0,
- paddingTop: 36,
- justifyContent: 'flex-end',
- paddingBottom: 28,
+ 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 9845238299..0000000000
--- a/src/components/Appbar/AppbarHeader.tsx
+++ /dev/null
@@ -1,146 +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, Props as AppbarProps } from './Appbar';
-import { getAppbarBackgroundColor, modeAppbarHeight } from './utils';
-import { useInternalTheme } from '../../core/theming';
-import type { ThemeProp } from '../../theme/types';
-
-export type Props = Omit & {
- /**
- * 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,
- ...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..013065cb60 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,
+} from './types';
+export type { AppbarProps } from './Appbar';
diff --git a/src/components/Appbar/types.ts b/src/components/Appbar/types.ts
new file mode 100644
index 0000000000..f119ae6904
--- /dev/null
+++ b/src/components/Appbar/types.ts
@@ -0,0 +1,155 @@
+import * as React from 'react';
+import type {
+ ColorValue,
+ StyleProp,
+ TextStyle,
+ View,
+ ViewProps,
+ ViewStyle,
+} from 'react-native';
+
+import type { AnimatedStyle } from 'react-native-reanimated';
+
+import type { ThemeProp } from '../../theme/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 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;
+
+export 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;
+};
+
+export type AppbarSearchbarProps = Omit<
+ SearchbarProps,
+ 'elevation' | 'mode' | 'showDivider' | 'theme'
+> & {
+ /** Search hint. Material guidance recommends including the word “Search”. */
+ placeholder: string;
+};
+
+// `Props` is declared in `Appbar.tsx` (co-located with the `Appbar` component)
+// so the docs-generation parser — which looks for a `Props` type-alias
+// declaration in the same source file as the component it documents — can
+// find it.
+export type { Props as AppbarProps } from './Appbar';
diff --git a/src/components/Appbar/utils.ts b/src/components/Appbar/utils.ts
index 36b2fd2f09..d8c9d7eca4 100644
--- a/src/components/Appbar/utils.ts
+++ b/src/components/Appbar/utils.ts
@@ -1,185 +1,57 @@
-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 '../../theme/types';
+import type { AppbarTrailingAction, AppbarVariant } from './types';
-export type AppbarModes = 'small' | 'medium' | 'large' | 'center-aligned';
+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;
-export type AppbarChildProps = {
- isLeading?: boolean;
- color: string;
- style?: StyleProp;
-};
-
-const borderStyleProperties = [
+const borderStyleProperties: readonly (keyof ViewStyle)[] = [
'borderRadius',
- 'borderBottomEndRadius',
- 'borderBottomStartRadius',
- 'borderEndEndRadius',
- 'borderEndStartRadius',
- 'borderStartEndRadius',
- 'borderStartStartRadius',
- 'borderTopEndRadius',
- 'borderTopStartRadius',
'borderTopLeftRadius',
'borderTopRightRadius',
'borderBottomRightRadius',
'borderBottomLeftRadius',
- '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 = {};
+ const borders: Record = {};
for (const property of borderStyleProperties) {
const value = style[property];
-
- if (typeof value === 'number' || typeof value === 'string') {
- borders = { ...borders, [property]: value };
+ if (value) {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ borders[property] = value as number;
}
}
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 d541c691bc..04a0d0ddee 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 0d6abb6029..471c509d77 100644
--- a/src/components/__tests__/Appbar/Appbar.test.tsx
+++ b/src/components/__tests__/Appbar/Appbar.test.tsx
@@ -1,311 +1,915 @@
-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 { render, screen } from '../../../test-utils';
-import { DarkTheme, LightTheme } from '../../../theme/schemes';
-import { tokens } from '../../../theme/tokens';
+import { render, screen, userEvent, waitFor } from '../../../test-utils';
+import { LightTheme } from '../../../theme/schemes';
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.getByText('Inbox')).toHaveStyle(
+ LightTheme.fonts[headlineVariant]
+ );
+ expect(screen.getByText('Inbox')).toHaveProp(
+ 'numberOfLines',
+ headlineLines
+ );
+ expect(screen.getByText('3 unread')).toHaveStyle({
+ ...LightTheme.fonts[subtitleVariant],
+ color: LightTheme.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(screen.getByText('Inbox')).toHaveStyle({
+ textAlign: 'center',
+ });
+ expect(screen.getByText('3 unread')).toHaveStyle({
+ textAlign: 'center',
+ });
+ expect(screen.getByTestId('leading-action').parent?.parent).toHaveStyle({
+ width: 96,
+ });
+ expect(screen.getByTestId('trailing-action').parent?.parent).toHaveStyle({
+ width: 96,
+ });
+
+ 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').parent?.parent).toHaveStyle({
+ width: 96,
+ });
+ expect(screen.getByTestId('trailing-action').parent?.parent).toHaveStyle({
+ width: 96,
+ });
+ expect(
+ screen.getByTestId('brand-mark', { includeHiddenElements: true })
+ ).toBeOnTheScreen();
+ });
- expect(tree).toMatchSnapshot();
+ it('adjusts leading headline spacing when a leading button is present', async () => {
+ const { rerender } = await render(
+
+ );
+
+ expect(screen.getByText('Inbox').parent).toHaveStyle({
+ marginStart: 12,
+ });
+
+ await rerender(
+
+ );
+
+ expect(screen.getByText('Inbox').parent).toHaveStyle({
+ marginStart: 4,
+ });
});
- it('passes additional props to AppbarBackAction, AppbarContent and AppbarAction', async () => {
- const tree = (
+ it.each(['medium-flexible', 'large-flexible'] as const)(
+ 'preserves intrinsic content sizing for the %s variant',
+ async (variant) => {
await render(
-
- {}} />
-
- {}} />
-
- )
- ).toJSON();
-
- expect(tree).toMatchSnapshot();
+
+ );
+
+ expect(screen.getByText('Inbox').parent).toHaveStyle({
+ flexBasis: 'auto',
+ });
+ }
+ );
+
+ it('renders only the headline image and drops the subtitle on a small app bar', async () => {
+ await render(
+ }
+ subtitle="3 unread"
+ />
+ );
+
+ expect(
+ screen.getByTestId('brand-mark', { includeHiddenElements: true })
+ ).toBeOnTheScreen();
+ expect(screen.queryByText('3 unread')).not.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,
+describe('Appbar surface', () => {
+ it('uses scroll container colors unless a custom background is supplied', async () => {
+ const customBackground = 'rebeccapurple';
+ const { rerender } = await render(
+
+ );
+
+ expect(screen.getByTestId(testIDPrefix).parent).toHaveStyle({
+ backgroundColor: LightTheme.colors.surface,
});
- expect(result).toHaveLength(4);
- });
+ await rerender(
+
+ );
+
+ expect(screen.getByTestId(testIDPrefix).parent).toHaveStyle({
+ backgroundColor: LightTheme.colors.surfaceContainer,
+ });
+
+ await rerender(
+
+ );
- 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(testIDPrefix).parent).toHaveStyle({
+ backgroundColor: customBackground,
});
+ });
- expect(result).toHaveLength(3);
+ it('applies border clipping and resolves safe-area overrides on the surface', async () => {
+ await render(
+
+
+
+ );
+
+ expect(screen.getByTestId(testIDPrefix).parent).toHaveStyle({
+ borderBottomLeftRadius: 16,
+ borderBottomRightRadius: 16,
+ paddingTop: 20,
+ paddingHorizontal: 12,
+ });
});
+});
- it('should render only children types specified in renderOnly', () => {
- const result = renderAppbarContent({
- children,
- isDark: false,
- renderOnly: ['Appbar.Action'],
+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}
+ />
+ );
+
+ expect(screen.getByTestId('leading-icon-color')).toHaveStyle({
+ backgroundColor: LightTheme.colors.onSurface,
});
+ expect(screen.getByTestId('trailing-icon-color')).toHaveStyle({
+ backgroundColor: LightTheme.colors.onSurfaceVariant,
+ });
+ expect(screen.getByTestId('custom-icon-color')).toHaveStyle({
+ backgroundColor: 'rebeccapurple',
+ });
+ });
+
+ it.each([
+ { variant: 'filled', color: LightTheme.colors.primary },
+ { variant: 'tonal', color: LightTheme.colors.secondaryContainer },
+ ] as const)(
+ 'maps $variant actions to their selected container color',
+ async ({ variant, color }) => {
+ await render(
+
+ );
+
+ expect(screen.getByTestId('expressive-action').parent).toHaveStyle({
+ backgroundColor: color,
+ });
+ expect(screen.getByTestId('expressive-action').parent).toHaveStyle({
+ width: 56,
+ });
+ }
+ );
- expect(result).toHaveLength(2);
+ 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 mode', () => {
- const result = renderAppbarContent({
- children,
- isDark: false,
- renderOnly: ['Appbar.Content'],
- mode: 'large',
+ 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').parent?.parent).toHaveStyle({
+ position: 'absolute',
+ left: 100,
+ top: 132,
+ });
});
+ expect(measureSpy).toHaveBeenCalled();
- // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion.
- expect(result[0].props.mode).toBe('large');
+ 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(
+
+ );
- it('should render centered AppbarContent', () => {
- const result = renderAppbarContent({
- children,
- isDark: false,
- renderOnly: ['Appbar.Content'],
- mode: 'center-aligned',
- shouldCenterContent: true,
+ expect(
+ screen.getByRole('searchbox', { name: 'Search messages' })
+ ).toBeOnTheScreen();
+ expect(
+ screen.getByRole('searchbox', { name: 'Search messages' }).parent
+ ).toHaveStyle({
+ backgroundColor: LightTheme.colors.surfaceContainer,
});
- const centerAlignedContent = {
- alignItems: 'center',
+ 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(result[0].props.style).toEqual(
- expect.arrayContaining([expect.objectContaining(centerAlignedContent)])
+ await render();
+
+ const searchbox = screen.getByRole('searchbox', {
+ name: 'Search messages',
+ });
+ expect(searchbox).toHaveStyle({
+ color: LightTheme.colors.onSurface,
+ });
+ expect(searchbox).toHaveProp(
+ 'placeholderTextColor',
+ LightTheme.colors.onSurfaceVariant
);
+ expect(screen.getByTestId('message-search').parent).toHaveStyle({
+ backgroundColor: LightTheme.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').parent).toHaveStyle({
+ width: '100%',
+ });
+ expect(screen.getByTestId('message-search').parent?.parent).toHaveStyle({
+ width: '100%',
+ maxWidth: 720,
+ });
});
- it('should render AppbarContent with correct spacings', () => {
- const renderResult = (withAppbarBackAction = false) =>
- renderAppbarContent({
- children,
- isDark: false,
- renderOnly: [
- 'Appbar.Content',
- withAppbarBackAction && 'Appbar.BackAction',
- ],
- });
+ it('warns when a search app bar is missing a searchBar', async () => {
+ jest.spyOn(console, 'warn').mockImplementation(() => {});
- const v3Spacing = {
- marginLeft: 12,
- };
+ await render();
- // 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)])
+ expect(console.warn).toHaveBeenCalledWith(
+ 'Appbar variant "search" is missing a searchBar, so its body will render blank'
);
+
+ jest.restoreAllMocks();
});
- it('Is recognized as a heading when no onPress callback has been passed', async () => {
+ it('does not warn when a search app bar has a searchBar', async () => {
+ jest.spyOn(console, 'warn').mockImplementation(() => {});
+
await render(
-
-
-
-
-
+
);
- expect(screen.getByRole('heading')).toBeOnTheScreen();
+ expect(console.warn).not.toHaveBeenCalled();
+
+ jest.restoreAllMocks();
});
- it('is recognized as a button when onPress callback has been passed', async () => {
+
+ it('renders a filled trailing action on a search app bar', async () => {
await render(
-
-
- {}} />
-
-
+
);
- expect(screen.getByRole('button')).toBeEnabled();
- expect(screen.queryByRole('heading')).not.toBeOnTheScreen();
+ expect(screen.getByTestId('expressive-action').parent).toHaveStyle({
+ backgroundColor: LightTheme.colors.primary,
+ });
});
- it('is recognized as a disabled button when onPress and disabled is 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(
-
-
- {}} disabled />
-
-
+
);
- expect(screen.getByRole('button')).toBeDisabled();
- expect(screen.queryByRole('heading')).not.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();
});
-});
-describe('AppbarAction', () => {
- it('should be rendered with default theme color', async () => {
- const { toJSON } = await render(
-
-
-
+ 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(toJSON()).toMatchSnapshot();
+
+ expect(screen.getByRole('button', { name: 'Inbox' })).toBeOnTheScreen();
+ expect(screen.getByRole('heading', { name: 'Inbox' })).toBeOnTheScreen();
});
- it('should be rendered with specific theme color if is leading', async () => {
- const { toJSON } = await render(
-
-
-
+ it('does not invoke a disabled headline action', async () => {
+ const onHeadlinePress = jest.fn();
+ await render(
+
);
- expect(toJSON()).toMatchSnapshot();
+
+ const headlineButton = screen.getByRole('button', {
+ name: 'Inbox',
+ disabled: true,
+ });
+ await userEvent.press(headlineButton);
+
+ expect(onHeadlinePress).not.toHaveBeenCalled();
});
- it('should be rendered with custom color', async () => {
- const { toJSON } = await render(
-
-
-
+ it('names a pressable small headline image from its fallback headline', async () => {
+ await render(
+ {}}
+ testID={testIDPrefix}
+ />
);
- expect(toJSON()).toMatchSnapshot();
+
+ expect(screen.getByRole('button', { name: 'Inbox' })).toBeOnTheScreen();
});
- it('should render AppbarBackAction with custom color', async () => {
- const { toJSON } = await render(
-
-
-
+ 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}
+ />
);
- expect(toJSON()).toMatchSnapshot();
+
+ const titleButton = screen.getByRole('button', {
+ name: 'Inbox options',
+ busy: true,
+ expanded: true,
+ });
+
+ expect(titleButton).toHaveProp('accessibilityHint', 'Opens the inbox menu');
+ });
+
+ it('uses default and custom labels for back buttons', async () => {
+ await render(
+
+
+
+
+ );
+
+ expect(screen.getByRole('button', { name: 'Back' })).toBeOnTheScreen();
+ expect(
+ screen.getByRole('button', { name: 'Return to inbox' })
+ ).toBeOnTheScreen();
});
-});
-describe('AppbarContent', () => {
- (['small', 'medium', 'large', 'center-aligned'] as const).forEach((mode) =>
- it(`should render text component with appropriate variant for ${mode} mode`, async () => {
+ it('preserves trailing action labels, hints, disabled state, and busy state', async () => {
+ await render(
+
+ );
+
+ 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();
+ });
+
+ it.each(['small', 'medium-flexible'] as const)(
+ 'hides decorative descendants of a %s headline image',
+ async (variant) => {
await render(
-
-
-
+ variant === 'small' ? (
+
+ ) : (
+
+ )
);
- expect(screen.getByText('Title')).toHaveStyle(
- LightTheme.fonts[modeTextVariant[mode]]
- );
- })
+ const imageContainer = screen.getByLabelText('Brand artwork', {
+ includeHiddenElements: true,
+ }).parent?.parent;
+
+ 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.each(writtenHeadlineVariants)(
+ 'warns when a %s app bar is missing a headline',
+ async (variant) => {
+ jest.spyOn(console, 'warn').mockImplementation(() => {});
- it('should return custom color no matter what is the theme version', () => {
- expect(
- getAppbarBackgroundColor(LightTheme, elevated, customBackground)
- ).toBe(customBackground);
- });
+ await render();
- it('returns the light surface container color for an elevated appbar', () => {
- expect(getAppbarBackgroundColor(LightTheme, elevated)).toBe(
- tokens.md.ref.palette.neutral94
- );
+ expect(console.warn).toHaveBeenCalledWith(
+ `Appbar variant "${variant}" is missing a headline, so it has no accessible heading`
+ );
+
+ jest.restoreAllMocks();
+ }
+ );
+
+ it('does not warn when a written headline variant has a headline', async () => {
+ jest.spyOn(console, 'warn').mockImplementation(() => {});
+
+ await render();
+
+ expect(console.warn).not.toHaveBeenCalled();
+
+ jest.restoreAllMocks();
});
- it('returns the dark surface container color for an elevated appbar', () => {
- expect(getAppbarBackgroundColor(DarkTheme, elevated)).toBe(
- tokens.md.ref.palette.neutral12
+ 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',
+ });
-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
+ await rerender(
+
);
- });
- it('returns an empty object when no border styles are passed', () => {
- expect(getAppbarBorders({ height: 60, top: 13 })).toEqual({});
+ expect(screen.getByRole('button', { name: 'Keep action' })).toBe(
+ survivingTrailingAction
+ );
});
});
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 faeb89916b..0000000000
--- a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap
+++ /dev/null
@@ -1,1998 +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
-
-
-
-
-
-
-
-`;
-
-exports[`AppbarAction should be rendered with custom color 1`] = `
-
-
-
-
-
-
-
-
- menu
-
-
-
-
-
-
-
-`;
-
-exports[`AppbarAction should be rendered with default theme color 1`] = `
-
-
-
-
-
-
-
-
- menu
-
-
-
-
-
-
-
-`;
-
-exports[`AppbarAction should be rendered with specific theme color if is leading 1`] = `
-
-
-
-
-
-
-
-
- menu
-
-
-
-
-
-
-
-`;
-
-exports[`AppbarAction should render AppbarBackAction with custom color 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
diff --git a/src/components/__tests__/Appbar/utils.test.ts b/src/components/__tests__/Appbar/utils.test.ts
new file mode 100644
index 0000000000..dff18b2684
--- /dev/null
+++ b/src/components/__tests__/Appbar/utils.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from '@jest/globals';
+
+import { getAppbarHeight, getTrailingActionsWidth } from '../../Appbar/utils';
+
+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 1807d2f249..4cbc148da0 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -54,11 +54,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 {
+ 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';