diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md
index da236a623b..2f4a4d4eae 100644
--- a/docs/6.x/docs/guides/migration.md
+++ b/docs/6.x/docs/guides/migration.md
@@ -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.
@@ -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 }) => (
+-
+-)}
++renderIcon={({ route, color }) =>
++ route.focusedIcon ? (
++
++ ) : 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 }) => (
+```
diff --git a/example/src/Examples/IconExample.tsx b/example/src/Examples/IconExample.tsx
index b08a389f5c..c7b298b2b0 100644
--- a/example/src/Examples/IconExample.tsx
+++ b/example/src/Examples/IconExample.tsx
@@ -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 }) => (
- {hasPreviousIcon ? (
+ {previousIcon ? (
diff --git a/src/components/Drawer/DrawerCollapsedItem.tsx b/src/components/Drawer/DrawerCollapsedItem.tsx
index 3934122598..dc9211bf0f 100644
--- a/src/components/Drawer/DrawerCollapsedItem.tsx
+++ b/src/components/Drawer/DrawerCollapsedItem.tsx
@@ -204,7 +204,9 @@ const DrawerCollapsedItem = ({
)}
)}
-
+ {icon !== undefined ? (
+
+ ) : null}
{label ? (
diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx
index 5714951fcb..c10bb88ad4 100644
--- a/src/components/Icon.tsx
+++ b/src/components/Icon.tsx
@@ -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 = {
/**
@@ -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 &&
@@ -63,7 +70,7 @@ export type Props = IconProps & {
/**
* Icon to display.
*/
- source: any;
+ source: IconSource;
/**
* Color of the icon.
*/
@@ -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)) {
diff --git a/src/components/SegmentedButtons/SegmentedButtonItem.tsx b/src/components/SegmentedButtons/SegmentedButtonItem.tsx
index 3b9962a457..1a66eabed8 100644
--- a/src/components/SegmentedButtons/SegmentedButtonItem.tsx
+++ b/src/components/SegmentedButtons/SegmentedButtonItem.tsx
@@ -227,7 +227,7 @@ const SegmentedButtonItem = ({
) : null}
- {showIcon ? (
+ {showIcon && icon ? (
diff --git a/src/components/__tests__/BottomNavigation.test.tsx b/src/components/__tests__/BottomNavigation.test.tsx
index 1020631055..7707b0b73b 100644
--- a/src/components/__tests__/BottomNavigation.test.tsx
+++ b/src/components/__tests__/BottomNavigation.test.tsx
@@ -284,7 +284,7 @@ it('renders custom icon and label in shifting bottom navigation', async () => {
onIndexChange={jest.fn()}
renderScene={renderScene}
renderIcon={({ route, color }) => (
-
+
)}
renderLabel={({ route, color }) => (
(
-
+
)}
renderLabel={({ route, color }) => (
{
});
it('renders correctly with function source', async () => {
- const source = ({ size, testID }: { size: number; testID: string }) => (
+ const source = ({ size, testID }: { size: number; testID?: string }) => (
{
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();
+
+ 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 */}
+
+ {/* @ts-expect-error: source is required, so undefined isn't allowed */}
+
+ {/* @ts-expect-error: null isn't an icon source */}
+
+ >
+ );
+
+ 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(
+
+
+
+ );
+
+ 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>(() => null);
+ await render(
+
+
+
+ );
+
+ expect(icon).toHaveBeenCalledWith(
+ expect.objectContaining({ name: 'camera', direction: expected })
+ );
+ }
+ );
});
diff --git a/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap b/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap
index 625e3e15ee..538cf1ad1c 100644
--- a/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap
+++ b/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap
@@ -10487,7 +10487,37 @@ exports[`renders custom icon and label in non-shifting bottom navigation 1`] = `
"top": 4,
}
}
- />
+ >
+
+ magnify
+
+
+ >
+
+ magnify
+
+
+ >
+
+ camera
+
+
+ >
+
+ camera
+
+
+ >
+
+ inbox
+
+
+ >
+
+ inbox
+
+
+ >
+
+ magnify
+
+
+ >
+
+ magnify
+
+
+ >
+
+ camera
+
+
+ >
+
+ camera
+
+
+ >
+
+ inbox
+
+
+ >
+
+ inbox
+
+
+ >
+
+ heart
+
+
+ >
+
+ heart
+
+
+ >
+
+ shopping-music
+
+
+ >
+
+ shopping-music
+
+