Skip to content
Merged
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
28 changes: 28 additions & 0 deletions docs/6.x/docs/guides/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ If you import it from Paper's type declarations instead, the path changed:
+import type { IconSource } from 'react-native-paper/lib/typescript/src/components/Icon';
```

`Icon`'s `source` prop now uses this type too, see [Icon](#icon).

#### `MD3Colors`

`MD3Colors` was renamed to `Palette`. It has every key `MD3Colors` had, such as `Palette.tertiary70`, plus new tones such as `primary98` and `neutral94`. The `neutral` tones from `neutral10` to `neutral99` are slightly different colors.
Expand Down Expand Up @@ -1279,3 +1281,29 @@ A standalone `Checkbox` has no visible label, so it now warns in development whe
+ theme={{ colors: { primary: 'tomato' } }}
+/>
```

### Icon

`Icon`'s `source` prop is typed as `IconSource` instead of `any`, so a value that isn't an icon name, an image source or a render function, such as a boolean or `undefined`, is now a type error. `Icon` still renders nothing for those values.

If you pass a field that can be `undefined`, such as an optional `focusedIcon` on a route in `BottomNavigation`'s `renderIcon`, check it first:

```diff
-renderIcon={({ route, color }) => (
- <Icon source={route.focusedIcon} color={color} size={24} />
-)}
+renderIcon={({ route, color }) =>
+ route.focusedIcon ? (
+ <Icon source={route.focusedIcon} color={color} size={24} />
+ ) : null
+}
```

A render function's props are now typed as what `Icon` passes: `size`, `color`, `direction` and `testID`. For an icon that isn't directional, `direction` is now `'ltr'`, where 5.x passed `null`, and the `icon` function in `PaperProvider`'s `settings` gets `'ltr'` too. Paper's own icons look the same, since they only flip for `'rtl'`.

`testID` is optional (`testID?: string`) in those props, so if you typed your render function's props with a required `testID: string`, you now get a type error. Make it optional:

```diff
-const renderIcon = ({ size, testID }: { size: number; testID: string }) => (
+const renderIcon = ({ size, testID }: { size: number; testID?: string }) => (
```
2 changes: 1 addition & 1 deletion example/src/Examples/IconExample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Icon, List } from 'react-native-paper';

import ScreenWrapper from '../ScreenWrapper';

const AssetIcon = ({ size, testID }: { size: number; testID: string }) => (
const AssetIcon = ({ size, testID }: { size: number; testID?: string }) => (
<Image
accessibilityIgnoresInvertColors
source={require('../../assets/images/paper-icon.png')}
Expand Down
2 changes: 1 addition & 1 deletion src/components/CrossFadeIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ const CrossFadeIcon = ({
},
]}
>
{hasPreviousIcon ? (
{previousIcon ? (
<Animated.View style={[styles.icon, previousIconStyle]}>
<Icon source={previousIcon} size={size} color={color} theme={theme} />
</Animated.View>
Expand Down
4 changes: 3 additions & 1 deletion src/components/Drawer/DrawerCollapsedItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,9 @@ const DrawerCollapsedItem = ({
)}
</View>
)}
<Icon source={icon} size={iconSize} color={iconColor} />
{icon !== undefined ? (
<Icon source={icon} size={iconSize} color={iconColor} />
) : null}
</View>

{label ? (
Expand Down
35 changes: 23 additions & 12 deletions src/components/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ type IconSourceBase = string | ImageSourcePropType;
export type IconSource =
| IconSourceBase
| Readonly<{ source: IconSourceBase; direction: 'rtl' | 'ltr' | 'auto' }>
| ((props: IconProps & { color: ColorValue }) => React.ReactNode);
| ((
props: IconProps & {
color: ColorValue;
direction: 'rtl' | 'ltr';
testID?: string;
}
) => React.ReactNode);

type IconProps = {
/**
Expand All @@ -24,7 +30,8 @@ type IconProps = {
allowFontScaling?: boolean;
};

const isImageSource = (source: any) =>
// On web this also passes image URL strings, which react-native-web's Image accepts
const isImageSource = (source: any): source is ImageSourcePropType =>
// source is an object with uri
(typeof source === 'object' &&
source !== null &&
Expand Down Expand Up @@ -63,7 +70,7 @@ export type Props = IconProps & {
/**
* Icon to display.
*/
source: any;
source: IconSource;
/**
* Color of the icon.
*/
Expand Down Expand Up @@ -108,17 +115,21 @@ const Icon = ({
}: Props) => {
const theme = useInternalTheme(themeOverrides);
const { direction: layoutDirection } = useLocale();
const direction =
typeof source === 'object' && source.direction && source.source
? source.direction === 'auto'
? layoutDirection
: source.direction
const directional =
typeof source === 'object' &&
source !== null &&
'direction' in source &&
source.direction &&
source.source
? source
: null;
// An icon that isn't directional gets 'ltr', so it never flips
const direction =
directional?.direction === 'auto'
? layoutDirection
: (directional?.direction ?? 'ltr');

const s =
typeof source === 'object' && source.direction && source.source
? source.source
: source;
const s = directional ? directional.source : source;
const iconColor = color || theme.colors.onSurface;

if (isImageSource(s)) {
Expand Down
2 changes: 1 addition & 1 deletion src/components/SegmentedButtons/SegmentedButtonItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ const SegmentedButtonItem = ({
<Icon source={'check'} size={iconSize} color={textColor} />
</Animated.View>
) : null}
{showIcon ? (
{showIcon && icon ? (
<Animated.View style={[iconStyle, iconAnimatedStyle]}>
<Icon source={icon} size={iconSize} color={textColor} />
</Animated.View>
Expand Down
4 changes: 2 additions & 2 deletions src/components/__tests__/BottomNavigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ it('renders custom icon and label in shifting bottom navigation', async () => {
onIndexChange={jest.fn()}
renderScene={renderScene}
renderIcon={({ route, color }) => (
<Icon color={color} source={route.unfocusedIcon} size={24} />
<Icon color={color} source={route.focusedIcon} size={24} />
)}
renderLabel={({ route, color }) => (
<Text
Expand All @@ -309,7 +309,7 @@ it('renders custom icon and label in non-shifting bottom navigation', async () =
onIndexChange={jest.fn()}
renderScene={renderScene}
renderIcon={({ route, color }) => (
<Icon color={color} source={route.unfocusedIcon} size={24} />
<Icon color={color} source={route.focusedIcon} size={24} />
)}
renderLabel={({ route, color }) => (
<Text
Expand Down
89 changes: 87 additions & 2 deletions src/components/__tests__/Icon.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { Image } from 'react-native';

import { describe, expect, it } from '@jest/globals';
import { describe, expect, it, jest } from '@jest/globals';

import type { Direction } from '../../core/locale';
import PaperProvider from '../../core/PaperProvider';
import type { Settings } from '../../core/settings';
import { render, screen } from '../../test-utils';
import Icon from '../Icon';
import type { IconSource } from '../Icon';

const ICON_SIZE = 24;

Expand Down Expand Up @@ -33,7 +37,7 @@ describe('Icon Component', () => {
});

it('renders correctly with function source', async () => {
const source = ({ size, testID }: { size: number; testID: string }) => (
const source = ({ size, testID }: { size: number; testID?: string }) => (
<Image
accessibilityIgnoresInvertColors
source={{ uri: 'https://picsum.photos/700' }}
Expand All @@ -52,4 +56,85 @@ describe('Icon Component', () => {
expect(functionIcon).toHaveStyle({ width: ICON_SIZE, height: ICON_SIZE });
expect(functionIcon).toBeOnTheScreen();
});

it('gives a render function the ltr direction when the icon is not directional', async () => {
const source = jest.fn(() => null);
await render(<Icon source={source} size={ICON_SIZE} />);

expect(source).toHaveBeenCalledWith(
expect.objectContaining({ direction: 'ltr' })
);
});

it('renders nothing for a source that is not an icon', async () => {
await render(
<>
{/* @ts-expect-error: a boolean isn't an icon source */}
<Icon source={true} size={ICON_SIZE} testID="boolean-icon" />
{/* @ts-expect-error: source is required, so undefined isn't allowed */}
<Icon source={undefined} size={ICON_SIZE} testID="undefined-icon" />
{/* @ts-expect-error: null isn't an icon source */}
<Icon source={null} size={ICON_SIZE} testID="null-icon" />
</>
);

expect(
screen.queryByTestId('boolean-icon', { includeHiddenElements: true })
).not.toBeOnTheScreen();
expect(
screen.queryByTestId('undefined-icon', { includeHiddenElements: true })
).not.toBeOnTheScreen();
expect(
screen.queryByTestId('null-icon', { includeHiddenElements: true })
).not.toBeOnTheScreen();
});

it.each<['rtl' | 'ltr' | 'auto', Direction, Direction]>([
['rtl', 'ltr', 'rtl'],
['rtl', 'rtl', 'rtl'],
['ltr', 'rtl', 'ltr'],
['auto', 'ltr', 'ltr'],
['auto', 'rtl', 'rtl'],
])(
'gives a render function with direction %s in a %s layout the %s direction',
async (direction, layoutDirection, expected) => {
const renderIcon = jest.fn(() => null);
await render(
<PaperProvider direction={layoutDirection}>
<Icon
// @ts-expect-error: the types only allow a name or an image source in a directional source, but Icon also calls a render function
source={{ source: renderIcon, direction }}
size={ICON_SIZE}
/>
</PaperProvider>
);

expect(renderIcon).toHaveBeenCalledWith(
expect.objectContaining({ direction: expected })
);
}
);

it.each<[IconSource, Direction, Direction]>([
['camera', 'ltr', 'ltr'],
['camera', 'rtl', 'ltr'],
[{ source: 'camera', direction: 'rtl' }, 'ltr', 'rtl'],
[{ source: 'camera', direction: 'ltr' }, 'rtl', 'ltr'],
[{ source: 'camera', direction: 'auto' }, 'ltr', 'ltr'],
[{ source: 'camera', direction: 'auto' }, 'rtl', 'rtl'],
])(
'gives the settings icon for source %j in a %s layout the %s direction',
async (source, layoutDirection, expected) => {
const icon = jest.fn<NonNullable<Settings['icon']>>(() => null);
await render(
<PaperProvider direction={layoutDirection} settings={{ icon }}>
<Icon source={source} size={ICON_SIZE} />
</PaperProvider>
);

expect(icon).toHaveBeenCalledWith(
expect.objectContaining({ name: 'camera', direction: expected })
);
}
);
});
Loading
Loading