diff --git a/example/src/ExampleList.tsx b/example/src/ExampleList.tsx index df140c1283..0a13af04a8 100644 --- a/example/src/ExampleList.tsx +++ b/example/src/ExampleList.tsx @@ -102,13 +102,15 @@ export default function ExampleList({ navigation }: Props) { const keyExtractor = (item: { id: string }) => item.id; - const { colors } = useTheme(); + const { colors, isV3, md } = useTheme(); const safeArea = useSafeArea(); return ( ; }; +type AppbarModes = 'small' | 'medium' | 'large' | 'center-aligned'; + const MORE_ICON = Platform.OS === 'ios' ? 'dots-horizontal' : 'dots-vertical'; const AppbarExample = ({ navigation }: Props) => { @@ -18,6 +28,10 @@ const AppbarExample = ({ navigation }: Props) => { const [showMoreIcon, setShowMoreIcon] = React.useState(true); const [showCustomColor, setShowCustomColor] = React.useState(false); const [showExactTheme, setShowExactTheme] = React.useState(false); + const [appbarMode, setAppbarMode] = React.useState('small'); + const [showCalendarIcon, setShowCalendarIcon] = React.useState(false); + + const { isV3 } = useTheme(); React.useLayoutEffect(() => { navigation.setOptions({ @@ -27,6 +41,7 @@ const AppbarExample = ({ navigation }: Props) => { theme={{ mode: showExactTheme ? 'exact' : 'adaptive', }} + mode={appbarMode} > {showLeftIcon && ( navigation.goBack()} /> @@ -35,6 +50,9 @@ const AppbarExample = ({ navigation }: Props) => { title="Title" subtitle={showSubtitle ? 'Subtitle' : null} /> + {showCalendarIcon && ( + {}} /> + )} {showSearchIcon && ( {}} /> )} @@ -52,8 +70,12 @@ const AppbarExample = ({ navigation }: Props) => { showMoreIcon, showCustomColor, showExactTheme, + appbarMode, + showCalendarIcon, ]); + const TextComponent = isV3 ? Text : Paragraph; + return ( <> { contentContainerStyle={styles.contentContainer} > - Left icon + Left icon + {!isV3 && ( + + Subtitle + + + )} - Subtitle - - - - Search icon + Search icon - More icon + More icon + {isV3 && ( + + Calendar icon + + + )} - Custom Color + Custom Color - Exact Dark Theme + Exact Dark Theme + {isV3 && ( + + setAppbarMode(value as AppbarModes) + } + > + Appbar Mode + + + Small (default) + + + + + Medium + + + + Large + + + + + Center-aligned + + + + + )} )} - + diff --git a/src/components/Appbar/Appbar.tsx b/src/components/Appbar/Appbar.tsx index 26d12b9b94..c6bf93d511 100644 --- a/src/components/Appbar/Appbar.tsx +++ b/src/components/Appbar/Appbar.tsx @@ -7,24 +7,28 @@ import AppbarAction from './AppbarAction'; import AppbarBackAction from './AppbarBackAction'; import Surface from '../Surface'; import { withTheme } from '../../core/theming'; -import { black, white } from '../../styles/themes/v2/colors'; -import overlay from '../../styles/overlay'; import type { Theme } from '../../types'; +import { AppbarModes, getAppbarColor, renderAppbarContent } from './utils'; -type Props = Partial> & { - /** - * Whether the background color is a dark color. A dark appbar will render light text and vice-versa. - */ - dark?: boolean; - /** - * Content of the `Appbar`. - */ - children: React.ReactNode; - /** - * @optional - */ - theme: Theme; - style?: StyleProp; +type Props = Partial> & + MD3Props & { + /** + * Whether the background color is a dark color. A dark appbar will render light text and vice-versa. + */ + dark?: boolean; + /** + * Content of the `Appbar`. + */ + children: React.ReactNode; + /** + * @optional + */ + theme: Theme; + style?: StyleProp; + }; + +type MD3Props = { + mode?: AppbarModes; }; export const DEFAULT_APPBAR_HEIGHT = 56; @@ -74,21 +78,25 @@ export const DEFAULT_APPBAR_HEIGHT = 56; * }); * ``` */ -const Appbar = ({ children, dark, style, theme, ...rest }: Props) => { - const { colors, dark: isDarkTheme, mode } = theme; +const Appbar = ({ + children, + dark, + style, + theme, + mode = 'small', + ...rest +}: Props) => { + const { isV3 } = theme; const { backgroundColor: customBackground, - elevation = 4, + elevation = isV3 ? 0 : 4, ...restStyle }: ViewStyle = StyleSheet.flatten(style) || {}; let isDark: boolean; - const backgroundColor = customBackground - ? customBackground - : isDarkTheme && mode === 'adaptive' - ? overlay(elevation, colors?.surface) - : colors?.primary; + const backgroundColor = getAppbarColor(theme, elevation, customBackground); + if (typeof dark === 'boolean') { isDark = dark; } else { @@ -121,48 +129,85 @@ const Appbar = ({ children, dark, style, theme, ...rest }: Props) => { }); shouldCenterContent = - hasAppbarContent && leftItemsCount < 2 && rightItemsCount < 2; - shouldAddLeftSpacing = shouldCenterContent && leftItemsCount === 0; - shouldAddRightSpacing = shouldCenterContent && rightItemsCount === 0; + !isV3 && hasAppbarContent && leftItemsCount < 2 && rightItemsCount < 2; + shouldAddLeftSpacing = !isV3 && shouldCenterContent && leftItemsCount === 0; + shouldAddRightSpacing = + !isV3 && shouldCenterContent && rightItemsCount === 0; } + + const isSmallMode = mode === 'small'; + const isMediumMode = mode === 'medium'; + const isLargeMode = mode === 'large'; + const isCenterAlignedMode = mode === 'center-aligned'; + + const filterAppbarActions = React.useCallback( + (isLeading = false) => + React.Children.toArray(children).filter((child) => + // @ts-expect-error: TypeScript complains about the type of type but it doesn't matter + isLeading ? child.props.isLeadingIcon : !child.props.isLeadingIcon + ), + [children] + ); + return ( {shouldAddLeftSpacing ? : null} - {React.Children.toArray(children) - .filter((child) => child != null && typeof child !== 'boolean') - .map((child, i) => { - if ( - !React.isValidElement(child) || - ![AppbarContent, AppbarAction, AppbarBackAction].includes( - // @ts-expect-error: TypeScript complains about the type of type but it doesn't matter - child.type - ) - ) { - return child; - } - - const props: { color?: string; style?: StyleProp } = { - color: - typeof child.props.color !== 'undefined' - ? child.props.color - : isDark - ? white - : black, - }; - - if (child.type === AppbarContent) { - props.style = [ - // Since content is not first item, add extra left margin - i !== 0 && { marginLeft: 8 }, - shouldCenterContent && { alignItems: 'center' }, - child.props.style, - ]; - } - return React.cloneElement(child, props); + {isSmallMode && + renderAppbarContent({ + children, + isDark, + shouldCenterContent, + theme, })} + {(isMediumMode || isLargeMode || isCenterAlignedMode) && ( + + {/* Appbar top row with controls */} + + {/* Left side of row container, can contain AppbarBackAction or AppbarAction if it's leading icon */} + {renderAppbarContent({ + children, + isDark, + theme, + renderOnly: [AppbarBackAction], + mode, + })} + {renderAppbarContent({ + children: filterAppbarActions(true), + isDark, + theme, + renderOnly: [AppbarAction], + mode, + })} + {/* Right side of row container, can contain other AppbarAction if they are not leading icons */} + + {renderAppbarContent({ + children: filterAppbarActions(false), + isDark, + theme, + renderOnly: [AppbarAction], + mode, + })} + + + {/* Middle of the row, can contain only AppbarContent */} + {renderAppbarContent({ + children, + isDark, + theme, + shouldCenterContent: isCenterAlignedMode, + renderOnly: [AppbarContent], + mode, + })} + + )} {shouldAddRightSpacing ? : null} ); @@ -179,6 +224,24 @@ const styles = StyleSheet.create({ spacing: { width: 48, }, + controlsRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + rightActionControls: { + flexDirection: 'row', + flex: 1, + justifyContent: 'flex-end', + }, + columnContainer: { + flexDirection: 'column', + flex: 1, + paddingTop: 8, + }, + centerAlignedContainer: { + paddingTop: 0, + }, }); export default withTheme(Appbar); diff --git a/src/components/Appbar/AppbarAction.tsx b/src/components/Appbar/AppbarAction.tsx index cffab3cd94..f294f1e65f 100644 --- a/src/components/Appbar/AppbarAction.tsx +++ b/src/components/Appbar/AppbarAction.tsx @@ -8,34 +8,40 @@ import type { import { black } from '../../styles/themes/v2/colors'; import IconButton from '../IconButton'; import type { IconSource } from '../Icon'; +import { useTheme } from '../../core/theming'; -type Props = React.ComponentPropsWithoutRef & { - /** - * Custom color for action icon. - */ - color?: string; - /** - * 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. - */ - accessibilityLabel?: string; - /** - * Function to execute on press. - */ - onPress?: () => void; - style?: StyleProp; - ref?: React.RefObject; +type Props = React.ComponentPropsWithoutRef & + MD3Props & { + /** + * Custom color for action icon. + */ + color?: string; + /** + * 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. + */ + accessibilityLabel?: string; + /** + * Function to execute on press. + */ + onPress?: () => void; + style?: StyleProp; + ref?: React.RefObject; + }; + +type MD3Props = { + isLeadingIcon?: boolean; }; /** @@ -75,24 +81,37 @@ type Props = React.ComponentPropsWithoutRef & { */ const AppbarAction = ({ size = 24, - color: iconColor = color(black).alpha(0.54).rgb().string(), + color: iconColor, icon, disabled, onPress, accessibilityLabel, + isLeadingIcon, ...rest -}: Props) => ( - -); +}: Props) => { + const { isV3, md } = useTheme(); + + const actionIconColor = iconColor + ? iconColor + : isV3 + ? isLeadingIcon + ? (md('md.sys.color.on-surface') as string) + : (md('md.sys.color.on-surface-variant') as string) + : color(black).alpha(0.54).rgb().string(); + + return ( + + ); +}; AppbarAction.displayName = 'Appbar.Action'; diff --git a/src/components/Appbar/AppbarBackAction.tsx b/src/components/Appbar/AppbarBackAction.tsx index 303c34170a..a9b1d662f6 100644 --- a/src/components/Appbar/AppbarBackAction.tsx +++ b/src/components/Appbar/AppbarBackAction.tsx @@ -68,6 +68,7 @@ const AppbarBackAction = ({ accessibilityLabel = 'Back', ...rest }: Props) => ( accessibilityLabel={accessibilityLabel} {...rest} icon={AppbarBackIcon} + isLeadingIcon /> ); diff --git a/src/components/Appbar/AppbarContent.tsx b/src/components/Appbar/AppbarContent.tsx index 1071d116c3..9d7bbef092 100644 --- a/src/components/Appbar/AppbarContent.tsx +++ b/src/components/Appbar/AppbarContent.tsx @@ -16,41 +16,49 @@ import { withTheme } from '../../core/theming'; import { white } from '../../styles/themes/v2/colors'; import type { $RemoveChildren, Theme } from '../../types'; +import type { AppbarModes } from './utils'; -type Props = $RemoveChildren & { - /** - * Custom color for the text. - */ - color?: string; - /** - * Text for the title. - */ - title: React.ReactNode; - /** - * Style for the title. - */ - titleStyle?: StyleProp; - /** - * Reference for the title. - */ - titleRef?: React.RefObject; - /** - * Text for the subtitle. - */ - subtitle?: React.ReactNode; - /** - * Style for the subtitle. - */ - subtitleStyle?: StyleProp; - /** - * Function to execute on press. - */ - onPress?: () => void; - style?: StyleProp; - /** - * @optional - */ - theme: Theme; +type Props = $RemoveChildren & + MD3Props & { + /** + * Custom color for the text. + */ + color?: string; + /** + * Text for the title. + */ + title: React.ReactNode; + /** + * Style for the title. + */ + titleStyle?: StyleProp; + /** + * Reference for the title. + */ + titleRef?: React.RefObject; + /** + * @deprecated + * Text for the subtitle. + */ + subtitle?: React.ReactNode; + /** + * @deprecated + * Style for the subtitle. + */ + subtitleStyle?: StyleProp; + /** + * Function to execute on press. + */ + onPress?: () => void; + style?: StyleProp; + /** + * @optional + */ + theme: Theme; + }; + +type MD3Props = { + mode?: AppbarModes; }; /** @@ -77,7 +85,7 @@ type Props = $RemoveChildren & { * ``` */ const AppbarContent = ({ - color: titleColor = white, + color: titleColor, subtitle, subtitleStyle, onPress, @@ -86,23 +94,57 @@ const AppbarContent = ({ titleStyle, theme, title, + mode = 'small', ...rest }: Props) => { - const { fonts } = theme; + const { fonts, isV3, md } = theme; const subtitleColor = color(titleColor).alpha(0.7).rgb().string(); + const titleTextColor = titleColor + ? titleColor + : isV3 + ? (md('md.sys.color.on-surface') as string) + : white; + + const getTextVariant = () => { + if (isV3) { + switch (mode) { + case 'small': + return 'title-large'; + case 'medium': + return 'headline-small'; + case 'large': + return 'headline-medium'; + case 'center-aligned': + return 'title-large'; + } + } + return undefined; + }; + + const isHigherAppbar = mode === 'large' || mode === 'medium'; + return ( - + {title} - {subtitle ? ( + {!isV3 && subtitle ? ( & { - /** - * 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; - /** - * @optional - */ - theme: Theme; - style?: StyleProp; +type Props = React.ComponentProps & + MD3Props & { + /** + * 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; + /** + * @optional + */ + theme: Theme; + style?: StyleProp; + }; + +type MD3Props = { + mode?: AppbarModes; }; /** @@ -82,22 +87,33 @@ const AppbarHeader = (props: Props) => { statusBarHeight = APPROX_STATUSBAR_HEIGHT, style, dark, + mode = 'small', ...rest } = props; - const { dark: isDarkTheme, colors, mode } = rest.theme; + const { isV3 } = rest.theme; + + const appbarHeight = { + small: DEFAULT_APPBAR_HEIGHT, + medium: 112, + large: 152, + 'center-aligned': DEFAULT_APPBAR_HEIGHT, + }; + const { - height = DEFAULT_APPBAR_HEIGHT, - elevation = 4, + height = isV3 ? appbarHeight[mode] : DEFAULT_APPBAR_HEIGHT, + elevation = isV3 ? 0 : 4, backgroundColor: customBackground, zIndex = 0, ...restStyle }: ViewStyle = StyleSheet.flatten(style) || {}; - const backgroundColor = customBackground - ? customBackground - : isDarkTheme && mode === 'adaptive' - ? overlay(elevation, colors?.surface) - : colors?.primary; + + const backgroundColor = getAppbarColor( + rest.theme, + elevation, + customBackground + ); + // Let the user override the behaviour const Wrapper = typeof props.statusBarHeight === 'number' ? View : SafeAreaView; @@ -117,6 +133,9 @@ const AppbarHeader = (props: Props) => { restStyle, ]} dark={dark} + {...(isV3 && { + mode, + })} {...rest} /> diff --git a/src/components/Appbar/utils.ts b/src/components/Appbar/utils.ts new file mode 100644 index 0000000000..81b810bdc1 --- /dev/null +++ b/src/components/Appbar/utils.ts @@ -0,0 +1,112 @@ +import React from 'react'; +import { StyleSheet } from 'react-native'; +import type { ColorValue, StyleProp, ViewStyle } from 'react-native'; +import AppbarContent from './AppbarContent'; +import AppbarAction from './AppbarAction'; +import AppbarBackAction from './AppbarBackAction'; +import overlay from '../../styles/overlay'; +import type { Theme } from '../../types'; +import { black, white } from '../../styles/themes/v2/colors'; + +export type AppbarModes = 'small' | 'medium' | 'large' | 'center-aligned'; + +export const getAppbarColor = ( + theme: Theme, + elevation: number, + customBackground?: ColorValue +) => { + const { isV3, md, dark: isDarkTheme, mode, colors } = theme; + const isAdaptiveMode = mode === 'adaptive'; + let backgroundColor; + if (customBackground) { + backgroundColor = customBackground; + } else if (isV3) { + backgroundColor = md('md.sys.color.surface') as string; + } else if (isDarkTheme && isAdaptiveMode) { + backgroundColor = overlay(elevation, colors?.surface); + } else backgroundColor = colors?.primary; + + return backgroundColor; +}; + +type RenderAppbarContentProps = { + children: React.ReactNode; + isDark: boolean; + shouldCenterContent?: boolean; + theme: Theme; + renderOnly?: ( + | typeof AppbarContent + | typeof AppbarAction + | typeof AppbarBackAction + )[]; + mode?: AppbarModes; +}; + +export const renderAppbarContent = ({ + children, + isDark, + shouldCenterContent = false, + theme, + renderOnly = [AppbarContent, AppbarAction, AppbarBackAction], + mode = 'small', +}: RenderAppbarContentProps) => { + const { isV3 } = theme; + return ( + React.Children.toArray(children) + .filter((child) => child != null && typeof child !== 'boolean') + // @ts-expect-error: TypeScript complains about the type of type but it doesn't matter + .filter((child) => renderOnly.includes(child.type)) + .map((child, i) => { + if ( + !React.isValidElement(child) || + ![AppbarContent, AppbarAction, AppbarBackAction].includes( + // @ts-expect-error: TypeScript complains about the type of type but it doesn't matter + child.type + ) + ) { + return child; + } + + const props: { + color?: string; + style?: StyleProp; + mode?: AppbarModes; + } = { + color: + typeof child.props.color !== 'undefined' + ? child.props.color + : isDark + ? white + : black, + }; + + if (child.type === AppbarContent) { + props.mode = mode; + props.style = [ + isV3 ? i === 0 && styles.v3Spacing : i !== 0 && styles.v2Spacing, + shouldCenterContent && isV3 && styles.v3CenterAlignedContent, + shouldCenterContent && !isV3 && styles.v2CenterAlignedContent, + child.props.style, + ]; + } + return React.cloneElement(child, props); + }) + ); +}; + +const styles = StyleSheet.create({ + v2Spacing: { + marginLeft: 8, + }, + v2CenterAlignedContent: { + alignItems: 'center', + }, + v3Spacing: { + marginLeft: 12, + }, + v3CenterAlignedContent: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.js.snap b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.js.snap index d895911928..0e85bfcfd5 100644 --- a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.js.snap +++ b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.js.snap @@ -19,199 +19,7 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` "shadowRadius": 4, } } -> - - - - - □ - - - - - - - - □ - - - - - +/> `; exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and AppbarAction 1`] = ` @@ -373,10 +181,14 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A "flex": 1, "paddingHorizontal": 12, }, + Object { + "paddingHorizontal": 0, + }, Array [ Object { "marginLeft": 8, }, + undefined, Object { "alignItems": "center", }, diff --git a/src/types.tsx b/src/types.tsx index 057bbcf146..8501d633d8 100644 --- a/src/types.tsx +++ b/src/types.tsx @@ -185,8 +185,8 @@ export type AllXOR = T extends [infer Only] type PathImpl = K extends string ? T[K] extends Record ? T[K] extends ArrayLike - ? K | `${K}.${PathImpl>>}` - : K | `${K}.${PathImpl}` + ? any + : any : K : never;