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 + +