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 ? ( - } - > + { console.log('Option 1 was pressed'); @@ -272,7 +287,7 @@ export default function CustomNavigationBar({ /> ) : 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()} /> - - setMenuVisible(false)} - anchor={ - setMenuVisible(true)} - /> - } - > - { - setSceneAnimation(undefined); - setMenuVisible(false); - }} - title="Scene animation: none" - /> - { - setSceneAnimation('shifting'); - setMenuVisible(false); - }} - title="Scene animation: shifting" - /> - { - setSceneAnimation('opacity'); - setMenuVisible(false); - }} - title="Scene animation: opacity" - /> - - + navigation.goBack(), + }} + trailingActions={[ + { + key: 'more', + icon: MORE_ICON, + 'aria-label': 'More options', + onPress: () => setMenuVisible(true), + }, + ]} + /> + setMenuVisible(false)} + anchor={{ x: Dimensions.get('window').width - 24, y: 56 }} + > + { + setSceneAnimation(undefined); + setMenuVisible(false); + }} + title="Scene animation: none" + /> + { + setSceneAnimation('shifting'); + setMenuVisible(false); + }} + title="Scene animation: shifting" + /> + { + setSceneAnimation('opacity'); + setMenuVisible(false); + }} + title="Scene animation: opacity" + /> + { 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) => ( + + {}} title="Undo" /> + {}} title="Redo" /> + + {}} title="Cut" disabled /> + {}} title="Copy" disabled /> + {}} title="Paste" /> + + ), + }, + ]} + /> { 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, - {}} />} - visible={false} - > - {null} - , - ], - 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) => ( + setVisible(false)} + anchor={button} + anchorPosition="bottom" + testID="menu" + > + {}} /> + + ), + }, + ]} + /> + + ); + }; + + 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';