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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions example/src/Examples/CardExample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,12 @@ const CardExample = () => {
<Card style={styles.card} mode={selectedMode}>
<Card.Cover source={require('../../assets/images/forest.jpg')} />
<Card.Actions>
<Button onPress={() => {}}>Share</Button>
<Button onPress={() => {}}>Explore</Button>
<Button mode="outlined" onPress={() => {}}>
Share
</Button>
<Button mode="contained" onPress={() => {}}>
Explore
</Button>
</Card.Actions>
</Card>
<Card style={styles.card} mode={selectedMode}>
Expand Down Expand Up @@ -104,10 +108,10 @@ const CardExample = () => {
/>
<Card.Title title="Custom Button styles" />
<Card.Actions>
<Button style={styles.button} onPress={() => {}}>
<Button mode="outlined" style={styles.button} onPress={() => {}}>
Share
</Button>
<Button style={styles.button} onPress={() => {}}>
<Button mode="contained" style={styles.button} onPress={() => {}}>
Explore
</Button>
</Card.Actions>
Expand Down
16 changes: 12 additions & 4 deletions example/src/Examples/TeamDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,12 @@ const News = () => {
</Text>
</Card.Content>
<Card.Actions>
<Button onPress={() => {}}>Share</Button>
<Button onPress={() => {}}>Read more</Button>
<Button mode="outlined" onPress={() => {}}>
Share
</Button>
<Button mode="contained" onPress={() => {}}>
Read more
</Button>
</Card.Actions>
</Card>
<Card style={styles.card} mode="contained">
Expand All @@ -110,8 +114,12 @@ const News = () => {
</Text>
</Card.Content>
<Card.Actions>
<Button onPress={() => {}}>Share</Button>
<Button onPress={() => {}}>Read more</Button>
<Button mode="outlined" onPress={() => {}}>
Share
</Button>
<Button mode="contained" onPress={() => {}}>
Read more
</Button>
</Card.Actions>
</Card>
</View>
Expand Down
166 changes: 51 additions & 115 deletions src/components/Appbar/Appbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,8 @@ import { Animated, StyleSheet, View } from 'react-native';
import type { ColorValue, StyleProp, ViewProps, ViewStyle } from 'react-native';

import AppbarContent from './AppbarContent';
import {
getAppbarBackgroundColor,
modeAppbarHeight,
renderAppbarContent,
filterAppbarActions,
} from './utils';
import { AppbarContext } from './AppbarContext';
import { getAppbarBackgroundColor, modeAppbarHeight } from './utils';
import type { AppbarModes, AppbarChildProps } from './utils';
import { useInternalTheme } from '../../core/theming';
import type { Elevation, ThemeProp } from '../../types';
Expand Down Expand Up @@ -174,45 +170,59 @@ const Appbar = ({

const isDark = typeof dark === 'boolean' ? dark : false;

const isCenterAlignedMode = isMode('center-aligned');

let shouldCenterContent = false;
let shouldAddLeftSpacing = false;
let shouldAddRightSpacing = false;
if (isCenterAlignedMode) {
let hasAppbarContent = false;
let leftItemsCount = 0;
let rightItemsCount = 0;

React.Children.forEach(children, (child) => {
if (React.isValidElement<AppbarChildProps>(child)) {
const isLeading = child.props.isLeading === true;

if (child.type === AppbarContent) {
hasAppbarContent = true;
} else if (isLeading || !hasAppbarContent) {
leftItemsCount++;
} else {
rightItemsCount++;
}
}
});

shouldCenterContent =
hasAppbarContent && leftItemsCount < 2 && rightItemsCount < 3;
shouldAddLeftSpacing = shouldCenterContent && leftItemsCount === 0;
shouldAddRightSpacing = shouldCenterContent && rightItemsCount === 0;
}

const spacingStyle = styles.v3Spacing;

const insets = {
paddingBottom: safeAreaInsets?.bottom,
paddingTop: safeAreaInsets?.top,
paddingLeft: safeAreaInsets?.left,
paddingRight: safeAreaInsets?.right,
};

const appbarContextValue = React.useMemo(
() => ({ isDark, mode }),
[isDark, mode]
);

let content: React.ReactNode = children;

if (isMode('medium') || isMode('large')) {
// Medium/large top app bars use a two-row layout: a controls row with the
// leading and trailing actions above a full-width title row. React Native
// flexbox has no `order`, so the title has to be separated from the actions
// structurally. We partition the children by element identity and the
// `isLeading` prop for layout only — nothing is injected into them; shared
// values flow through `AppbarContext`.
const items = React.Children.toArray(children).filter(
React.isValidElement
) as React.ReactElement<AppbarChildProps>[];
Comment on lines +194 to +196

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This now fails lint on main. The "avoid unnecessary type assertions" commit on main enabled @typescript-eslint/no-unsafe-type-assertion as an error; running current main's config over this file gives 194:19 Unsafe type assertion. (The pre-existing cast at :155 is fine — main added a disable comment for it.) A type predicate avoids the cast and the house rule both:

-const items = React.Children.toArray(children).filter(
-  React.isValidElement
-) as React.ReactElement<AppbarChildProps>[];
+const items = React.Children.toArray(children).filter(
+  (child): child is React.ReactElement<AppbarChildProps> =>
+    React.isValidElement(child)
+);

const isAppbarContent = (child: React.ReactElement<AppbarChildProps>) => {
const { type } = child;
// React.memo(AppbarContent) wraps the component in an object whose
// `.type` holds the original component — unwrap it so memoized
// Content still lands in the title row.
const innerType =
typeof type === 'object' && type !== null && 'type' in type
? (type as { type: unknown }).type
: type;
return innerType === AppbarContent;
};
const titleItems = items.filter(isAppbarContent);
const actionItems = items.filter((child) => !isAppbarContent(child));
const leadingActions = actionItems.filter((child) => child.props.isLeading);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Appbar.BackAction lands on the right in mode="medium" and "large". AppbarBackAction doesn't accept isLeading — it sets it on the inner AppbarAction it renders (AppbarBackAction.tsx:69), so the element's own props.isLeading is undefined and it falls into trailingActions. By render, main puts the back button directly in the controls row; here both icons sit inside rightActionControls (justifyContent: 'flex-end'). AppbarExample.tsx:52-54 renders a BackAction by default, so the PR screenshots should show it.

Note that defaulting isLeading = true inside AppbarBackAction won't fix this: React 19 dropped defaultProps, so the authored element.props the partition reads stays undefined. Recognise AppbarBackAction by identity as AppbarContent already is, or move to explicit slots (below) — and please add a placement test.

const trailingActions = actionItems.filter(
(child) => !child.props.isLeading
);

content = (
<View style={styles.columnContainer}>
<View style={styles.controlsRow}>
{leadingActions}
<View style={styles.rightActionControls}>{trailingActions}</View>
</View>
{titleItems}
</View>
);
}
Comment on lines +187 to +224

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

what do you think about replacing type-based partition with explicit leading / title / actions slots? Children.toArray can't see custom wrapper’s output, so <WrappedContent /> is still placed in trailing actions

so, we could add these props:

type Props = Omit<Partial<ViewProps>, 'style'> & {
  children?: React.ReactNode;
  leading?: React.ReactNode;
  title?: React.ReactNode;
  actions?: React.ReactNode;
  ...
};

const Appbar = ({
  children,
  leading,
  title,
  actions,
  ...
}: Props) => {

simplify this whole block:

Suggested change
if (isMode('medium') || isMode('large')) {
// Medium/large top app bars use a two-row layout: a controls row with the
// leading and trailing actions above a full-width title row. React Native
// flexbox has no `order`, so the title has to be separated from the actions
// structurally. We partition the children by element identity and the
// `isLeading` prop for layout only — nothing is injected into them; shared
// values flow through `AppbarContext`.
const items = React.Children.toArray(children).filter(
React.isValidElement
) as React.ReactElement<AppbarChildProps>[];
const isAppbarContent = (child: React.ReactElement<AppbarChildProps>) => {
const { type } = child;
// React.memo(AppbarContent) wraps the component in an object whose
// `.type` holds the original component — unwrap it so memoized
// Content still lands in the title row.
const innerType =
typeof type === 'object' && type !== null && 'type' in type
? (type as { type: unknown }).type
: type;
return innerType === AppbarContent;
};
const titleItems = items.filter(isAppbarContent);
const actionItems = items.filter((child) => !isAppbarContent(child));
const leadingActions = actionItems.filter((child) => child.props.isLeading);
const trailingActions = actionItems.filter(
(child) => !child.props.isLeading
);
content = (
<View style={styles.columnContainer}>
<View style={styles.controlsRow}>
{leadingActions}
<View style={styles.rightActionControls}>{trailingActions}</View>
</View>
{titleItems}
</View>
);
}
if (isMode('medium') || isMode('large')) {
content = (
<View style={styles.columnContainer}>
<View style={styles.controlsRow}>
{leading}
<View style={styles.rightActionControls}>{actions}</View>
</View>
{title}
</View>
);
}

and usage then might be smth like that:

<Appbar
  mode="medium"
  leading={<Appbar.BackAction onPress={goBack} />}
  title={<WrappedContent />}
  actions={<Appbar.Action icon="menu" onPress={openMenu} />}
/>

but that's a breaking API change for medium/large Appbars, so all existing usages & docs would need migration


return (
<Surface
style={[
Expand All @@ -228,77 +238,9 @@ const Appbar = ({
container
{...rest}
>
{shouldAddLeftSpacing ? <View style={spacingStyle} /> : 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')) && (
<View
style={[
styles.columnContainer,
isMode('center-aligned') && styles.centerAlignedContainer,
]}
>
{/* Appbar top row with controls */}
<View style={styles.controlsRow}>
{/* 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 */}
<View style={styles.rightActionControls}>
{renderAppbarContent({
children: filterAppbarActions(children),
isDark,
renderExcept: [
'Appbar',
'Appbar.BackAction',
'Appbar.Content',
'Appbar.Header',
],
mode,
})}
</View>
</View>
{renderAppbarContent({
children,
isDark,
renderOnly: ['Appbar.Content'],
mode,
})}
</View>
)}
{shouldAddRightSpacing ? <View style={spacingStyle} /> : null}
<AppbarContext.Provider value={appbarContextValue}>
{content}
</AppbarContext.Provider>
Comment on lines +241 to +243

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

maybe we should keep shouldAddLeftSpacing / shouldAddRightSpacing logic here & render 52px spacer on missing side? otherwise, I suppose, one-sided action would shift center-aligned title by 26px

Suggested change
<AppbarContext.Provider value={appbarContextValue}>
{content}
</AppbarContext.Provider>
{shouldAddLeftSpacing ? <View style={spacingStyle} /> : null}
<AppbarContext.Provider value={appbarContextValue}>
{content}
</AppbarContext.Provider>
{shouldAddRightSpacing ? <View style={spacingStyle} /> : null}

</Surface>
);
};
Expand All @@ -309,9 +251,6 @@ const styles = StyleSheet.create({
alignItems: 'center',
paddingHorizontal: 4,
},
v3Spacing: {
width: 52,
},
controlsRow: {
flex: 1,
flexDirection: 'row',
Expand All @@ -328,9 +267,6 @@ const styles = StyleSheet.create({
flex: 1,
paddingTop: 8,
},
centerAlignedContainer: {
paddingTop: 0,
},
});

export default Appbar;
Expand Down
11 changes: 8 additions & 3 deletions src/components/Appbar/AppbarAction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import type {
ViewStyle,
} from 'react-native';

import { useAppbarContext } from './AppbarContext';
import { useInternalTheme } from '../../core/theming';
import { white } from '../../theme/colors';
import type { Theme, ThemeProp } from '../../types';
import type { IconSource } from '../Icon';
import IconButton from '../IconButton/IconButton';
Expand Down Expand Up @@ -87,12 +89,15 @@ const AppbarAction = ({
}: Props) => {
const theme = useInternalTheme(themeOverrides);
const { colors } = theme as Theme;
const { isDark = false } = useAppbarContext() ?? {};

const actionIconColor = iconColor
? iconColor
: isLeading
? colors.onSurface
: colors.onSurfaceVariant;
: isDark
? white
: isLeading
? colors.onSurface
: colors.onSurfaceVariant;

return (
<IconButton
Expand Down
21 changes: 18 additions & 3 deletions src/components/Appbar/AppbarContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import type {
ViewProps,
} from 'react-native';

import { useAppbarContext } from './AppbarContext';
import { modeTextVariant } from './utils';
import { useInternalTheme } from '../../core/theming';
import { white } from '../../theme/colors';
import type {
$RemoveChildren,
Theme,
Expand Down Expand Up @@ -98,21 +100,27 @@ const AppbarContent = ({
titleStyle,
title,
titleMaxFontSizeMultiplier,
mode = 'small',
mode: modeOverride,
theme: themeOverrides,
testID = 'appbar-content',
...rest
}: Props) => {
const theme = useInternalTheme(themeOverrides);
const { colors, fonts } = theme as Theme;
const { isDark = false, mode: contextMode } = useAppbarContext() ?? {};
const mode = modeOverride ?? contextMode ?? 'small';

const titleTextColor = titleColor ? titleColor : colors.onSurface;
const titleTextColor = titleColor
? titleColor
: isDark
? white
: colors.onSurface;

const modeContainerStyles = {
small: styles.v3DefaultContainer,
medium: styles.v3MediumContainer,
large: styles.v3LargeContainer,
'center-aligned': styles.v3DefaultContainer,
'center-aligned': styles.v3CenterAlignedContainer,
};

const variant = modeTextVariant[mode] as TypescaleKey;
Expand Down Expand Up @@ -177,17 +185,24 @@ const styles = StyleSheet.create({
},
v3DefaultContainer: {
paddingHorizontal: 0,
marginLeft: 12,
},
v3CenterAlignedContainer: {
paddingHorizontal: 0,
alignItems: 'center',
},
v3MediumContainer: {
paddingHorizontal: 0,
justifyContent: 'flex-end',
paddingBottom: 24,
marginLeft: 12,
},
v3LargeContainer: {
paddingHorizontal: 0,
paddingTop: 36,
justifyContent: 'flex-end',
paddingBottom: 28,
marginLeft: 12,
},
});

Expand Down
28 changes: 28 additions & 0 deletions src/components/Appbar/AppbarContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import * as React from 'react';

import type { AppbarModes } from './utils';

export type AppbarContextType = {
/**
* Whether the Appbar background is dark, so children can derive a
* contrasting default foreground color without it being injected.
*/
isDark: boolean;
/**
* The Appbar mode, consumed by `Appbar.Content` to pick the title text
* variant and container layout.
*/
mode: AppbarModes;
};

/**
* Shared Appbar values provided to `Appbar.Action`, `Appbar.BackAction` and
* `Appbar.Content` via context instead of being injected with `cloneElement`.
* This keeps composition intact: children can be wrapped, reordered or
* conditionally rendered without losing the values they need.
*/
export const AppbarContext = React.createContext<AppbarContextType | null>(
null
);

export const useAppbarContext = () => React.useContext(AppbarContext);
Loading