From b61df18f285bff636b64f0a708bd441071d75ef2 Mon Sep 17 00:00:00 2001 From: Satyajit Sahoo Date: Wed, 26 Aug 2026 14:08:46 +0200 Subject: [PATCH] refactor: rework Surface and elevation shadows --- docs/6.x/docs/components/Card/Card.mdx | 8 + docs/6.x/docs/components/Chip/Chip.mdx | 8 + docs/6.x/docs/components/Modal.mdx | 34 +- docs/6.x/docs/components/Surface.mdx | 134 +- docs/6.x/docs/guides/migration.md | 150 + docs/src/data/componentDocs6x.json | 504 +- example/src/Examples/BannerExample.tsx | 46 +- example/src/Examples/SurfaceExample.tsx | 33 +- jest/testSetup.js | 11 +- src/components/Appbar/Appbar.tsx | 44 +- src/components/Appbar/AppbarAction.tsx | 12 +- src/components/Appbar/AppbarBackAction.tsx | 5 +- src/components/Appbar/AppbarHeader.tsx | 62 +- src/components/Appbar/utils.ts | 24 +- src/components/Badge.tsx | 4 +- src/components/Banner.tsx | 100 +- .../BottomNavigation/BottomNavigationBar.tsx | 471 +- src/components/Button/Button.tsx | 64 +- src/components/Card/Card.tsx | 67 +- src/components/Chip/Chip.tsx | 54 +- src/components/Dialog/Dialog.tsx | 16 +- src/components/FAB/Extended.tsx | 38 +- src/components/FAB/FAB.tsx | 4 +- src/components/FAB/Shell.tsx | 61 +- src/components/FAB/useVisibility.ts | 102 - src/components/IconButton/IconButton.tsx | 28 +- src/components/Menu/Menu.tsx | 246 +- src/components/Modal.tsx | 143 +- src/components/Searchbar.tsx | 23 +- src/components/Snackbar.tsx | 180 +- src/components/Surface.tsx | 447 +- src/components/ToggleButton/ToggleButton.tsx | 6 +- .../__tests__/Appbar/Appbar.test.tsx | 126 +- .../Appbar/__snapshots__/Appbar.test.tsx.snap | 1858 +++--- src/components/__tests__/Banner.test.tsx | 34 +- .../__tests__/BottomNavigation.test.tsx | 10 +- src/components/__tests__/Button.test.tsx | 34 +- src/components/__tests__/Card/Card.test.tsx | 34 +- .../Card/__snapshots__/Card.test.tsx.snap | 256 +- .../__snapshots__/Checkbox.test.tsx.snap | 574 ++ .../__snapshots__/CheckboxItem.test.tsx.snap | 164 + src/components/__tests__/Chip.test.tsx | 34 +- src/components/__tests__/IconButton.test.tsx | 32 +- src/components/__tests__/Menu.test.tsx | 23 +- src/components/__tests__/Modal.test.tsx | 76 +- src/components/__tests__/Searchbar.test.tsx | 31 +- src/components/__tests__/Snackbar.test.tsx | 35 +- src/components/__tests__/Surface.test.tsx | 199 +- .../__tests__/ToggleButton.test.tsx | 40 +- .../__snapshots__/Badge.test.tsx.snap | 183 +- .../__snapshots__/Banner.test.tsx.snap | 3753 +++++++++--- .../BottomNavigation.test.tsx.snap | 3835 ++++++++++-- .../__snapshots__/Button.test.tsx.snap | 5140 ++++++++++++----- .../__snapshots__/Chip.test.tsx.snap | 2898 +++++++--- .../__snapshots__/DataTable.test.tsx.snap | 3284 ++++++----- .../__tests__/__snapshots__/FAB.test.tsx.snap | 3438 ++++++++++- .../__snapshots__/FABExtended.test.tsx.snap | 1814 +++++- .../__snapshots__/FABMenu.test.tsx.snap | 4178 +++++++++++++- .../__snapshots__/IconButton.test.tsx.snap | 1209 ++-- .../__snapshots__/ListItem.test.tsx.snap | 454 +- .../__snapshots__/Menu.test.tsx.snap | 1712 ++++-- .../__snapshots__/Searchbar.test.tsx.snap | 2141 ++++--- .../__snapshots__/Snackbar.test.tsx.snap | 1554 +++-- .../__snapshots__/Switch.test.tsx.snap | 866 ++- .../__snapshots__/TextInput.test.tsx.snap | 2764 +++++---- .../__snapshots__/ToggleButton.test.tsx.snap | 765 +-- src/theme/tokens/sys/elevation.ts | 145 +- src/utils/animations.ts | 5 - 68 files changed, 34980 insertions(+), 11847 deletions(-) delete mode 100644 src/components/FAB/useVisibility.ts delete mode 100644 src/utils/animations.ts diff --git a/docs/6.x/docs/components/Card/Card.mdx b/docs/6.x/docs/components/Card/Card.mdx index 0248a53318..de2e1b19cb 100644 --- a/docs/6.x/docs/components/Card/Card.mdx +++ b/docs/6.x/docs/components/Card/Card.mdx @@ -159,6 +159,14 @@ export default MyComponent; +
+ +### ref + +
+ + + diff --git a/docs/6.x/docs/components/Chip/Chip.mdx b/docs/6.x/docs/components/Chip/Chip.mdx index f712768036..f1a72dc03d 100644 --- a/docs/6.x/docs/components/Chip/Chip.mdx +++ b/docs/6.x/docs/components/Chip/Chip.mdx @@ -268,6 +268,14 @@ export default MyComponent;
+### ref + +
+ + + +
+ ### role
diff --git a/docs/6.x/docs/components/Modal.mdx b/docs/6.x/docs/components/Modal.mdx index 72691042b1..f94e758cad 100644 --- a/docs/6.x/docs/components/Modal.mdx +++ b/docs/6.x/docs/components/Modal.mdx @@ -28,12 +28,18 @@ const MyComponent = () => { const showModal = () => setVisible(true); const hideModal = () => setVisible(false); - const containerStyle = { backgroundColor: 'white', padding: 20 }; + + const containerStyle = { padding: 20 }; return ( - + Example Modal. Click outside this area to dismiss. @@ -111,6 +117,30 @@ export default MyComponent;
+### contentBackgroundColor + +
+ + + +
+ +### contentBorderRadius + +
+ + + +
+ +### contentElevation + +
+ + + +
+ ### style
diff --git a/docs/6.x/docs/components/Surface.mdx b/docs/6.x/docs/components/Surface.mdx index 841a74e3e4..37ac687703 100644 --- a/docs/6.x/docs/components/Surface.mdx +++ b/docs/6.x/docs/components/Surface.mdx @@ -9,9 +9,9 @@ import ScreenshotTabs from '@docs/components/ScreenshotTabs.tsx'; import ExtendedExample from '@docs/components/ExtendedExample.tsx'; Surface is a basic container that can give depth to an element with elevation shadow. -On dark theme with `adaptive` mode, surface is constructed by also placing a semi-transparent white overlay over a component surface. -See [Dark Theme](https://callstack.github.io/react-native-paper/docs/guides/theming#dark-theme) for more information. -Overlay and shadow can be applied by specifying the `elevation` property both on Android and iOS. + +On Android, Surface uses the native `elevation` style, +and falls back to shadows that approximate the elevation on other platforms. @@ -26,7 +26,7 @@ import { Surface, Text } from 'react-native-paper'; import { StyleSheet } from 'react-native'; const MyComponent = () => ( - + Surface ); @@ -35,9 +35,9 @@ export default MyComponent; const styles = StyleSheet.create({ surface: { - padding: 8, height: 80, width: 80, + padding: 8, alignItems: 'center', justifyContent: 'center', }, @@ -52,11 +52,123 @@ const styles = StyleSheet.create({
-### children (required) +### backgroundColor
- + + +
+ +### borderRadius + +
+ + + +
+ +### borderBottomEndRadius + +
+ + + +
+ +### borderBottomLeftRadius + +
+ + + +
+ +### borderBottomRightRadius + +
+ + + +
+ +### borderBottomStartRadius + +
+ + + +
+ +### borderEndEndRadius + +
+ + + +
+ +### borderEndStartRadius + +
+ + + +
+ +### borderStartEndRadius + +
+ + + +
+ +### borderStartStartRadius + +
+ + + +
+ +### borderTopEndRadius + +
+ + + +
+ +### borderTopLeftRadius + +
+ + + +
+ +### borderTopRightRadius + +
+ + + +
+ +### borderTopStartRadius + +
+ + + +
+ +### borderCurve + +
+ +
@@ -92,6 +204,14 @@ const styles = StyleSheet.create({
+### children (required) + +
+ + + +
+ ### testID
diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 1fcd32bd25..d2661c8dec 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -4,8 +4,158 @@ title: Migration from Paper 5.x to 6.x TBC +## General changes + +### Animations + +React Native Paper 6 uses [Reanimated](https://docs.swmansion.com/react-native-reanimated/) for animations as opposed to the built-in React Native `Animated` API. + +The following props now accept animated styles returned from `useAnimatedStyle`. They no longer accept `Animated.Value` or `Animated.AnimatedInterpolation` where these were previously supported: + +- `Appbar.Action` and `Appbar.BackAction`: `style` +- `Badge`: `style` +- `Banner`: `style` +- `Button`: `style` +- `Card`: `style` +- `Chip`: `style` +- `Dialog`: `style` +- `FAB` and `FAB.Extended`: `style` +- `IconButton`: `style` +- `Menu`: `contentStyle` +- `Modal`: `contentContainerStyle` +- `Searchbar`: `style` +- `Snackbar`: `style` +- `Surface`: `style` +- `ToggleButton`: `style` + +So you can use Reanimated's `useSharedValue` and `useAnimatedStyle` to animate these components instead of the React Native `Animated` API. + +```tsx +import { useAnimatedStyle, useSharedValue } from 'react-native-reanimated'; + +const MyComponent = () => { + const opacity = useSharedValue(1); + const animatedStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + })); + + return Button; +}; +``` + +### Elevation + +The `elevation` prop no longer accepts a React Native `Animated.Value` in the following components: + +- `Banner` +- `Card` +- `Searchbar` +- `Snackbar` +- `Surface` + +You can use an elevation level from `0` to `5` instead. Changes to the elevation level are animated automatically. + +### Styles + +The following components no longer support overriding their background color or border radius with the `style` prop: + +- `Banner` +- `Button` +- `Card` +- `Chip` +- `Dialog` +- `Searchbar` +- `Snackbar` + +You can use the component's color prop where available, or override the corresponding theme colors. + +### Test IDs + +Some hardcoded and generated test IDs have been removed for the following components: + +- `Appbar.Header`: `${testID}-root-layer` +- `Surface`: `surface` and `${testID}-outer-layer` + +You can specify a `testID` explicitly and use that value to query the component. + ## Components +### Appbar + +The `style` props for `Appbar` and `Appbar.Header` no longer accept `Animated.Value` or `Animated.AnimatedInterpolation`. They only accept static styles. + +### Surface + +- The `elevation` prop no longer accepts a React Native `Animated.Value`. Any `elevation` changes are animated automatically. +- The `style` prop no longer configures background and border radius. We have added new props for these: + - `backgroundColor` + - `borderRadius` + - `borderBottomEndRadius` + - `borderBottomLeftRadius` + - `borderBottomRightRadius` + - `borderBottomStartRadius` + - `borderEndEndRadius` + - `borderEndStartRadius` + - `borderStartEndRadius` + - `borderStartStartRadius` + - `borderTopEndRadius` + - `borderTopLeftRadius` + - `borderTopRightRadius` + - `borderTopStartRadius` + - `borderCurve` +- The `pointerEvents` prop is no longer supported as it's deprecated in React Native Web. You can specify `pointerEvents` in the `style` prop instead. +- The `overflow: 'hidden'` style is no longer supported in `style` as it can clip shadows. You can nest a `View` inside the `Surface` and apply `overflow: 'hidden'` to that instead. +- The default `testID` for `Surface` was removed. You can specify a `testID` explicitly if you need it. + +e.g.: + +```diff + ++ + Content ++ + +``` + +### Modal + +- The `contentContainerStyle` prop no longer configures the background color or border radius. We have added new props for these: + - `contentBackgroundColor` + - `contentBorderRadius` +- We have added the `contentElevation` prop to configure the elevation of the modal content. + +e.g.: + +```diff + + Content + +``` + +### Dialog + +- The default elevation changed from level `1` to level `3`. +- The `style` prop no longer configures the background color or border radius. You can override `theme.colors.surfaceContainerHigh` and `theme.shapes.corner.extraLarge` using the `theme` prop instead. + ### TextInput The Paper 6.x `TextInput` is a complete rewrite with a new API. Import the component the same way, but note that the props and behavior have changed significantly. diff --git a/docs/src/data/componentDocs6x.json b/docs/src/data/componentDocs6x.json index 300c315b99..b322a31882 100644 --- a/docs/src/data/componentDocs6x.json +++ b/docs/src/data/componentDocs6x.json @@ -153,7 +153,11 @@ "tsType": { "name": "boolean" }, - "description": "@supported Available in v5.x with theme version 3\nWhether Appbar background should have the elevation along with primary color pigment." + "description": "@supported Available in v5.x with theme version 3\nWhether Appbar background should have the elevation along with primary color pigment.", + "defaultValue": { + "value": "false", + "computed": false + } }, "safeAreaInsets": { "required": false, @@ -206,19 +210,13 @@ "style": { "required": false, "tsType": { - "name": "Animated.WithAnimatedValue", + "name": "StyleProp", "elements": [ { - "name": "StyleProp", - "elements": [ - { - "name": "ViewStyle" - } - ], - "raw": "StyleProp" + "name": "ViewStyle" } ], - "raw": "Animated.WithAnimatedValue>" + "raw": "StyleProp" }, "description": "" } @@ -304,19 +302,19 @@ "style": { "required": false, "tsType": { - "name": "Animated.WithAnimatedValue", + "name": "StyleProp", "elements": [ { - "name": "StyleProp", + "name": "AnimatedStyle", "elements": [ { "name": "ViewStyle" } ], - "raw": "StyleProp" + "raw": "AnimatedStyle" } ], - "raw": "Animated.WithAnimatedValue>" + "raw": "StyleProp>" }, "description": "" }, @@ -416,19 +414,19 @@ "style": { "required": false, "tsType": { - "name": "Animated.WithAnimatedValue", + "name": "StyleProp", "elements": [ { - "name": "StyleProp", + "name": "AnimatedStyle", "elements": [ { "name": "ViewStyle" } ], - "raw": "StyleProp" + "raw": "AnimatedStyle" } ], - "raw": "Animated.WithAnimatedValue>" + "raw": "StyleProp>" }, "description": "" }, @@ -694,19 +692,13 @@ "style": { "required": false, "tsType": { - "name": "Animated.WithAnimatedValue", + "name": "StyleProp", "elements": [ { - "name": "StyleProp", - "elements": [ - { - "name": "ViewStyle" - } - ], - "raw": "StyleProp" + "name": "ViewStyle" } ], - "raw": "Animated.WithAnimatedValue>" + "raw": "StyleProp" }, "description": "" }, @@ -1032,10 +1024,16 @@ "name": "StyleProp", "elements": [ { - "name": "TextStyle" + "name": "AnimatedStyle", + "elements": [ + { + "name": "TextStyle" + } + ], + "raw": "AnimatedStyle" } ], - "raw": "StyleProp" + "raw": "StyleProp>" }, "description": "" }, @@ -1148,7 +1146,7 @@ "required": false, "tsType": { "name": "union", - "raw": "0 | 1 | 2 | 3 | 4 | 5 | Animated.Value", + "raw": "0 | 1 | 2 | 3 | 4 | 5", "elements": [ { "name": "literal", @@ -1173,9 +1171,6 @@ { "name": "literal", "value": "5" - }, - { - "name": "Animated.Value" } ] }, @@ -1195,19 +1190,19 @@ "style": { "required": false, "tsType": { - "name": "Animated.WithAnimatedValue", + "name": "StyleProp", "elements": [ { - "name": "StyleProp", + "name": "AnimatedStyle", "elements": [ { "name": "ViewStyle" } ], - "raw": "StyleProp" + "raw": "AnimatedStyle" } ], - "raw": "Animated.WithAnimatedValue>" + "raw": "StyleProp>" }, "description": "" }, @@ -1234,7 +1229,35 @@ "onShowAnimationFinished": { "required": false, "tsType": { - "name": "Animated.EndCallback" + "name": "signature", + "type": "function", + "raw": "(result: { finished: boolean }) => void", + "signature": { + "arguments": [ + { + "name": "result", + "type": { + "name": "signature", + "type": "object", + "raw": "{ finished: boolean }", + "signature": { + "properties": [ + { + "key": "finished", + "value": { + "name": "boolean", + "required": true + } + } + ] + } + } + } + ], + "return": { + "name": "void" + } + } }, "description": "\nOptional callback that will be called after the opening animation finished running normally", "defaultValue": { @@ -1245,7 +1268,35 @@ "onHideAnimationFinished": { "required": false, "tsType": { - "name": "Animated.EndCallback" + "name": "signature", + "type": "function", + "raw": "(result: { finished: boolean }) => void", + "signature": { + "arguments": [ + { + "name": "result", + "type": { + "name": "signature", + "type": "object", + "raw": "{ finished: boolean }", + "signature": { + "properties": [ + { + "key": "finished", + "value": { + "name": "boolean", + "required": true + } + } + ] + } + } + } + ], + "return": { + "name": "void" + } + } }, "description": "\nOptional callback that will be called after the closing animation finished running normally", "defaultValue": { @@ -3226,19 +3277,19 @@ "style": { "required": false, "tsType": { - "name": "Animated.WithAnimatedValue", + "name": "StyleProp", "elements": [ { - "name": "StyleProp", + "name": "AnimatedStyle", "elements": [ { "name": "ViewStyle" } ], - "raw": "StyleProp" + "raw": "AnimatedStyle" } ], - "raw": "Animated.WithAnimatedValue>" + "raw": "StyleProp>" }, "description": "" }, @@ -3450,7 +3501,7 @@ "required": false, "tsType": { "name": "union", - "raw": "0 | 1 | 2 | 3 | 4 | 5 | Animated.Value", + "raw": "0 | 1 | 2 | 3 | 4 | 5", "elements": [ { "name": "literal", @@ -3475,9 +3526,6 @@ { "name": "literal", "value": "5" - }, - { - "name": "Animated.Value" } ] }, @@ -3503,19 +3551,19 @@ "style": { "required": false, "tsType": { - "name": "Animated.WithAnimatedValue", + "name": "StyleProp", "elements": [ { - "name": "StyleProp", + "name": "AnimatedStyle", "elements": [ { "name": "ViewStyle" } ], - "raw": "StyleProp" + "raw": "AnimatedStyle" } ], - "raw": "Animated.WithAnimatedValue>" + "raw": "StyleProp>" }, "description": "" }, @@ -3543,6 +3591,19 @@ "name": "boolean" }, "description": "Pass down accessible from card props to touchable" + }, + "ref": { + "required": false, + "tsType": { + "name": "ReactRef", + "raw": "React.Ref", + "elements": [ + { + "name": "View" + } + ] + }, + "description": "Reference to the card container." } } }, @@ -4590,19 +4651,19 @@ "style": { "required": false, "tsType": { - "name": "Animated.WithAnimatedValue", + "name": "StyleProp", "elements": [ { - "name": "StyleProp", + "name": "AnimatedStyle", "elements": [ { "name": "ViewStyle" } ], - "raw": "StyleProp" + "raw": "AnimatedStyle" } ], - "raw": "Animated.WithAnimatedValue>" + "raw": "StyleProp>" }, "description": "" }, @@ -4646,6 +4707,19 @@ }, "description": "Specifies the largest possible scale a text font can reach." }, + "ref": { + "required": false, + "tsType": { + "name": "ReactRef", + "raw": "React.Ref", + "elements": [ + { + "name": "View" + } + ] + }, + "description": "Reference to the chip container." + }, "role": { "defaultValue": { "value": "'button'", @@ -5273,19 +5347,19 @@ "style": { "required": false, "tsType": { - "name": "Animated.WithAnimatedValue", + "name": "StyleProp", "elements": [ { - "name": "StyleProp", + "name": "AnimatedStyle", "elements": [ { "name": "ViewStyle" } ], - "raw": "StyleProp" + "raw": "AnimatedStyle" } ], - "raw": "Animated.WithAnimatedValue>" + "raw": "StyleProp>" }, "description": "" }, @@ -6124,10 +6198,16 @@ "name": "StyleProp", "elements": [ { - "name": "ViewStyle" + "name": "AnimatedStyle", + "elements": [ + { + "name": "ViewStyle" + } + ], + "raw": "AnimatedStyle" } ], - "raw": "StyleProp" + "raw": "StyleProp>" }, "description": "Style for positioning the FAB. The visual treatment (size, shape, color)\nis driven by `variant` and `size`." }, @@ -6339,10 +6419,16 @@ "name": "StyleProp", "elements": [ { - "name": "ViewStyle" + "name": "AnimatedStyle", + "elements": [ + { + "name": "ViewStyle" + } + ], + "raw": "AnimatedStyle" } ], - "raw": "StyleProp" + "raw": "StyleProp>" }, "description": "Style for positioning the FAB. The visual treatment (size, shape, color)\nis driven by `variant` and `size`." }, @@ -7929,19 +8015,19 @@ "style": { "required": false, "tsType": { - "name": "Animated.WithAnimatedValue", + "name": "StyleProp", "elements": [ { - "name": "StyleProp", + "name": "AnimatedStyle", "elements": [ { "name": "ViewStyle" } ], - "raw": "StyleProp" + "raw": "AnimatedStyle" } ], - "raw": "Animated.WithAnimatedValue>" + "raw": "StyleProp>" }, "description": "" }, @@ -9059,19 +9145,19 @@ "contentStyle": { "required": false, "tsType": { - "name": "Animated.WithAnimatedValue", + "name": "StyleProp", "elements": [ { - "name": "StyleProp", + "name": "AnimatedStyle", "elements": [ { "name": "ViewStyle" } ], - "raw": "StyleProp" + "raw": "AnimatedStyle" } ], - "raw": "Animated.WithAnimatedValue>" + "raw": "StyleProp>" }, "description": "Style of menu's inner content." }, @@ -9375,10 +9461,10 @@ "Modal": { "filepath": "Modal.tsx", "title": "Modal", - "description": "The Modal component is a simple way to present content above an enclosing view.\nTo render the `Modal` above other components, you'll need to wrap it with the [`Portal`](./Portal) component.\nNote that this modal is NOT accessible by default; if you need an accessible modal, please use the React Native Modal.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { Modal, Portal, Text, Button, PaperProvider } from 'react-native-paper';\n\nconst MyComponent = () => {\n const [visible, setVisible] = React.useState(false);\n\n const showModal = () => setVisible(true);\n const hideModal = () => setVisible(false);\n const containerStyle = { backgroundColor: 'white', padding: 20 };\n\n return (\n \n \n \n Example Modal. Click outside this area to dismiss.\n \n \n \n \n );\n};\n\nexport default MyComponent;\n```", + "description": "The Modal component is a simple way to present content above an enclosing view.\nTo render the `Modal` above other components, you'll need to wrap it with the [`Portal`](./Portal) component.\nNote that this modal is NOT accessible by default; if you need an accessible modal, please use the React Native Modal.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { Modal, Portal, Text, Button, PaperProvider } from 'react-native-paper';\n\nconst MyComponent = () => {\n const [visible, setVisible] = React.useState(false);\n\n const showModal = () => setVisible(true);\n const hideModal = () => setVisible(false);\n\n const containerStyle = { padding: 20 };\n\n return (\n \n \n \n Example Modal. Click outside this area to dismiss.\n \n \n \n \n );\n};\n\nexport default MyComponent;\n```", "link": "modal", "data": { - "description": "The Modal component is a simple way to present content above an enclosing view.\nTo render the `Modal` above other components, you'll need to wrap it with the [`Portal`](./Portal) component.\nNote that this modal is NOT accessible by default; if you need an accessible modal, please use the React Native Modal.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { Modal, Portal, Text, Button, PaperProvider } from 'react-native-paper';\n\nconst MyComponent = () => {\n const [visible, setVisible] = React.useState(false);\n\n const showModal = () => setVisible(true);\n const hideModal = () => setVisible(false);\n const containerStyle = { backgroundColor: 'white', padding: 20 };\n\n return (\n \n \n \n Example Modal. Click outside this area to dismiss.\n \n \n \n \n );\n};\n\nexport default MyComponent;\n```", + "description": "The Modal component is a simple way to present content above an enclosing view.\nTo render the `Modal` above other components, you'll need to wrap it with the [`Portal`](./Portal) component.\nNote that this modal is NOT accessible by default; if you need an accessible modal, please use the React Native Modal.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { Modal, Portal, Text, Button, PaperProvider } from 'react-native-paper';\n\nconst MyComponent = () => {\n const [visible, setVisible] = React.useState(false);\n\n const showModal = () => setVisible(true);\n const hideModal = () => setVisible(false);\n\n const containerStyle = { padding: 20 };\n\n return (\n \n \n \n Example Modal. Click outside this area to dismiss.\n \n \n \n \n );\n};\n\nexport default MyComponent;\n```", "displayName": "Modal", "methods": [], "statics": [], @@ -9457,21 +9543,68 @@ "contentContainerStyle": { "required": false, "tsType": { - "name": "Animated.WithAnimatedValue", + "name": "StyleProp", "elements": [ { - "name": "StyleProp", + "name": "AnimatedStyle", "elements": [ { - "name": "ViewStyle" + "name": "Omit", + "elements": [ + { + "name": "ViewStyle" + }, + { + "name": "union", + "raw": "'backgroundColor' | 'borderRadius'", + "elements": [ + { + "name": "literal", + "value": "'backgroundColor'" + }, + { + "name": "literal", + "value": "'borderRadius'" + } + ] + } + ], + "raw": "Omit" } ], - "raw": "StyleProp" + "raw": "AnimatedStyle>" } ], - "raw": "Animated.WithAnimatedValue>" + "raw": "StyleProp<\n AnimatedStyle>\n>" + }, + "description": "Style for the content of the modal.\n\nBackground color and border radius should be specified via props instead:\n- `contentBackgroundColor`\n- `contentBorderRadius`" + }, + "contentBackgroundColor": { + "required": false, + "tsType": { + "name": "SurfaceProps['backgroundColor']", + "raw": "SurfaceProps['backgroundColor']" + }, + "description": "Background color of the modal content. Defaults to transparent.", + "defaultValue": { + "value": "'transparent'", + "computed": false + } + }, + "contentBorderRadius": { + "required": false, + "tsType": { + "name": "SurfaceProps['borderRadius']", + "raw": "SurfaceProps['borderRadius']" + }, + "description": "Border radius of the modal content." + }, + "contentElevation": { + "required": false, + "tsType": { + "name": "Elevation" }, - "description": "Style for the content of the modal" + "description": "Elevation level of the modal content. Defaults to level 1." }, "style": { "required": false, @@ -10547,7 +10680,7 @@ "required": false, "tsType": { "name": "union", - "raw": "0 | 1 | 2 | 3 | 4 | 5 | Animated.Value", + "raw": "0 | 1 | 2 | 3 | 4 | 5", "elements": [ { "name": "literal", @@ -10572,9 +10705,6 @@ { "name": "literal", "value": "5" - }, - { - "name": "Animated.Value" } ] }, @@ -10600,19 +10730,19 @@ "style": { "required": false, "tsType": { - "name": "Animated.WithAnimatedValue", + "name": "StyleProp", "elements": [ { - "name": "StyleProp", + "name": "AnimatedStyle", "elements": [ { "name": "ViewStyle" } ], - "raw": "StyleProp" + "raw": "AnimatedStyle" } ], - "raw": "Animated.WithAnimatedValue>" + "raw": "StyleProp>" }, "description": "" }, @@ -11019,7 +11149,7 @@ "required": false, "tsType": { "name": "union", - "raw": "0 | 1 | 2 | 3 | 4 | 5 | Animated.Value", + "raw": "0 | 1 | 2 | 3 | 4 | 5", "elements": [ { "name": "literal", @@ -11044,9 +11174,6 @@ { "name": "literal", "value": "5" - }, - { - "name": "Animated.Value" } ] }, @@ -11092,19 +11219,19 @@ "style": { "required": false, "tsType": { - "name": "Animated.WithAnimatedValue", + "name": "StyleProp", "elements": [ { - "name": "StyleProp", + "name": "AnimatedStyle", "elements": [ { "name": "ViewStyle" } ], - "raw": "StyleProp" + "raw": "AnimatedStyle" } ], - "raw": "Animated.WithAnimatedValue>" + "raw": "StyleProp>" }, "description": "" }, @@ -11145,54 +11272,166 @@ "Surface": { "filepath": "Surface.tsx", "title": "Surface", - "description": "Surface is a basic container that can give depth to an element with elevation shadow.\nOn dark theme with `adaptive` mode, surface is constructed by also placing a semi-transparent white overlay over a component surface.\nSee [Dark Theme](https://callstack.github.io/react-native-paper/docs/guides/theming#dark-theme) for more information.\nOverlay and shadow can be applied by specifying the `elevation` property both on Android and iOS.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { Surface, Text } from 'react-native-paper';\nimport { StyleSheet } from 'react-native';\n\nconst MyComponent = () => (\n \n Surface\n \n);\n\nexport default MyComponent;\n\nconst styles = StyleSheet.create({\n surface: {\n padding: 8,\n height: 80,\n width: 80,\n alignItems: 'center',\n justifyContent: 'center',\n },\n});\n```", + "description": "Surface is a basic container that can give depth to an element with elevation shadow.\n\nOn Android, Surface uses the native `elevation` style,\nand falls back to shadows that approximate the elevation on other platforms.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { Surface, Text } from 'react-native-paper';\nimport { StyleSheet } from 'react-native';\n\nconst MyComponent = () => (\n \n Surface\n \n);\n\nexport default MyComponent;\n\nconst styles = StyleSheet.create({\n surface: {\n height: 80,\n width: 80,\n padding: 8,\n alignItems: 'center',\n justifyContent: 'center',\n },\n});\n```", "link": "surface", "data": { - "description": "Surface is a basic container that can give depth to an element with elevation shadow.\nOn dark theme with `adaptive` mode, surface is constructed by also placing a semi-transparent white overlay over a component surface.\nSee [Dark Theme](https://callstack.github.io/react-native-paper/docs/guides/theming#dark-theme) for more information.\nOverlay and shadow can be applied by specifying the `elevation` property both on Android and iOS.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { Surface, Text } from 'react-native-paper';\nimport { StyleSheet } from 'react-native';\n\nconst MyComponent = () => (\n \n Surface\n \n);\n\nexport default MyComponent;\n\nconst styles = StyleSheet.create({\n surface: {\n padding: 8,\n height: 80,\n width: 80,\n alignItems: 'center',\n justifyContent: 'center',\n },\n});\n```", + "description": "Surface is a basic container that can give depth to an element with elevation shadow.\n\nOn Android, Surface uses the native `elevation` style,\nand falls back to shadows that approximate the elevation on other platforms.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { Surface, Text } from 'react-native-paper';\nimport { StyleSheet } from 'react-native';\n\nconst MyComponent = () => (\n \n Surface\n \n);\n\nexport default MyComponent;\n\nconst styles = StyleSheet.create({\n surface: {\n height: 80,\n width: 80,\n padding: 8,\n alignItems: 'center',\n justifyContent: 'center',\n },\n});\n```", "displayName": "Surface", "methods": [], "statics": [], "props": { - "children": { - "required": true, + "backgroundColor": { + "required": false, "tsType": { - "name": "ReactReactNode", - "raw": "React.ReactNode" + "name": "Extract['backgroundColor']", + "raw": "Extract<\n AnimatedStyle>>,\n Record\n>[Key]" }, - "description": "Content of the `Surface`." + "description": "Background color of the Surface. Overrides the color derived from\n`elevation`." + }, + "borderRadius": { + "required": false, + "tsType": { + "name": "Extract['borderRadius']", + "raw": "Extract<\n AnimatedStyle>>,\n Record\n>[Key]" + }, + "description": "Radius of every corner of the Surface." + }, + "borderBottomEndRadius": { + "required": false, + "tsType": { + "name": "Extract['borderRadius']", + "raw": "Extract<\n AnimatedStyle>>,\n Record\n>[Key]" + }, + "description": "Radius of the bottom-end corner of the Surface." + }, + "borderBottomLeftRadius": { + "required": false, + "tsType": { + "name": "Extract['borderRadius']", + "raw": "Extract<\n AnimatedStyle>>,\n Record\n>[Key]" + }, + "description": "Radius of the bottom-left corner of the Surface." + }, + "borderBottomRightRadius": { + "required": false, + "tsType": { + "name": "Extract['borderRadius']", + "raw": "Extract<\n AnimatedStyle>>,\n Record\n>[Key]" + }, + "description": "Radius of the bottom-right corner of the Surface." + }, + "borderBottomStartRadius": { + "required": false, + "tsType": { + "name": "Extract['borderRadius']", + "raw": "Extract<\n AnimatedStyle>>,\n Record\n>[Key]" + }, + "description": "Radius of the bottom-start corner of the Surface." + }, + "borderEndEndRadius": { + "required": false, + "tsType": { + "name": "Extract['borderRadius']", + "raw": "Extract<\n AnimatedStyle>>,\n Record\n>[Key]" + }, + "description": "Radius of the end-end corner of the Surface." + }, + "borderEndStartRadius": { + "required": false, + "tsType": { + "name": "Extract['borderRadius']", + "raw": "Extract<\n AnimatedStyle>>,\n Record\n>[Key]" + }, + "description": "Radius of the end-start corner of the Surface." + }, + "borderStartEndRadius": { + "required": false, + "tsType": { + "name": "Extract['borderRadius']", + "raw": "Extract<\n AnimatedStyle>>,\n Record\n>[Key]" + }, + "description": "Radius of the start-end corner of the Surface." + }, + "borderStartStartRadius": { + "required": false, + "tsType": { + "name": "Extract['borderRadius']", + "raw": "Extract<\n AnimatedStyle>>,\n Record\n>[Key]" + }, + "description": "Radius of the start-start corner of the Surface." + }, + "borderTopEndRadius": { + "required": false, + "tsType": { + "name": "Extract['borderRadius']", + "raw": "Extract<\n AnimatedStyle>>,\n Record\n>[Key]" + }, + "description": "Radius of the top-end corner of the Surface." + }, + "borderTopLeftRadius": { + "required": false, + "tsType": { + "name": "Extract['borderRadius']", + "raw": "Extract<\n AnimatedStyle>>,\n Record\n>[Key]" + }, + "description": "Radius of the top-left corner of the Surface." + }, + "borderTopRightRadius": { + "required": false, + "tsType": { + "name": "Extract['borderRadius']", + "raw": "Extract<\n AnimatedStyle>>,\n Record\n>[Key]" + }, + "description": "Radius of the top-right corner of the Surface." + }, + "borderTopStartRadius": { + "required": false, + "tsType": { + "name": "Extract['borderRadius']", + "raw": "Extract<\n AnimatedStyle>>,\n Record\n>[Key]" + }, + "description": "Radius of the top-start corner of the Surface." + }, + "borderCurve": { + "required": false, + "tsType": { + "name": "ViewStyle['borderCurve']", + "raw": "ViewStyle['borderCurve']" + }, + "description": "Corner curve of the Surface on iOS." }, "style": { "required": false, "tsType": { - "name": "Animated.WithAnimatedValue", + "name": "StyleProp", "elements": [ { - "name": "StyleProp", + "name": "AnimatedStyle", "elements": [ { - "name": "ViewStyle" + "name": "Omit", + "elements": [ + { + "name": "ViewStyle" + }, + { + "name": "unknown" + } + ], + "raw": "Omit" } ], - "raw": "StyleProp" + "raw": "AnimatedStyle>" } ], - "raw": "Animated.WithAnimatedValue>" + "raw": "StyleProp>>" }, - "description": "" + "description": "Style of the Surface.\n\nThis doesn't support all View style properties:\n- Background color and border radius should be specified via props instead.\n- `overflow: 'hidden'` is not supported with `elevation` as it can clip the shadow.\n To achieve the same effect, wrap the content in a child View with the overflow style." }, "elevation": { "required": false, "tsType": { - "name": "union", - "raw": "Elevation | Animated.Value", - "elements": [ - { - "name": "Elevation" - }, - { - "name": "Animated.Value" - } - ] + "name": "Elevation" }, "description": "@supported Available in v5.x with theme version 3\nChanges shadows and background on iOS and Android.\nUsed to create UI hierarchy between components.\n\nNote: If `mode` is set to `flat`, Surface doesn't have a shadow.\n\nNote: In version 2 the `elevation` prop was accepted via `style` prop i.e. `style={{ elevation: 4 }}`.\nIt's no longer supported with theme version 3 and you should use `elevation` property instead.", "defaultValue": { @@ -11229,16 +11468,20 @@ }, "description": "" }, + "children": { + "required": true, + "tsType": { + "name": "ReactReactNode", + "raw": "React.ReactNode" + }, + "description": "Content of the `Surface`." + }, "testID": { "required": false, "tsType": { "name": "string" }, - "description": "TestID used for testing purposes", - "defaultValue": { - "value": "'surface'", - "computed": false - } + "description": "TestID used for testing purposes" }, "ref": { "required": false, @@ -11252,13 +11495,6 @@ ] }, "description": "" - }, - "container": { - "required": false, - "tsType": { - "name": "boolean" - }, - "description": "@internal" } } }, @@ -11780,19 +12016,19 @@ "style": { "required": false, "tsType": { - "name": "Animated.WithAnimatedValue", + "name": "StyleProp", "elements": [ { - "name": "StyleProp", + "name": "AnimatedStyle", "elements": [ { "name": "ViewStyle" } ], - "raw": "StyleProp" + "raw": "AnimatedStyle" } ], - "raw": "Animated.WithAnimatedValue>" + "raw": "StyleProp>" }, "description": "" }, diff --git a/example/src/Examples/BannerExample.tsx b/example/src/Examples/BannerExample.tsx index 5679a92518..2a99b38866 100644 --- a/example/src/Examples/BannerExample.tsx +++ b/example/src/Examples/BannerExample.tsx @@ -1,6 +1,5 @@ import * as React from 'react'; import { Dimensions, Image, Platform, StyleSheet, View } from 'react-native'; -import type { LayoutChangeEvent } from 'react-native'; import { Banner, FAB, Palette, useTheme } from 'react-native-paper'; @@ -15,13 +14,6 @@ const BannerExample = () => { const [useCustomTheme, setUseCustomTheme] = React.useState(false); const defaultTheme = useTheme(); - const [height, setHeight] = React.useState(0); - - const handleLayout = ({ nativeEvent }: LayoutChangeEvent) => { - const { height: layoutHeight } = nativeEvent.layout; - setHeight(layoutHeight); - }; - const customTheme = { ...defaultTheme, colors: { @@ -37,23 +29,7 @@ const BannerExample = () => { return ( - - - {PHOTOS.map((uri) => ( - - - - ))} - - - setVisible(!visible)} /> { console.log('Completed closing animation') } theme={useCustomTheme ? customTheme : defaultTheme} - style={styles.banner} > Two line text string with two actions. One to two lines is preferable on mobile. + + + {PHOTOS.map((uri) => ( + + + + ))} + + + setVisible(!visible)} /> ); }; @@ -117,12 +107,6 @@ const styles = StyleSheet.create({ }, }, }), - banner: { - position: 'absolute', - top: 0, - left: 0, - width: '100%', - }, photo: { flex: 1, }, diff --git a/example/src/Examples/SurfaceExample.tsx b/example/src/Examples/SurfaceExample.tsx index fe420830ce..c8225e5a5e 100644 --- a/example/src/Examples/SurfaceExample.tsx +++ b/example/src/Examples/SurfaceExample.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Animated, ScrollView, StyleSheet, View } from 'react-native'; +import { ScrollView, StyleSheet, View } from 'react-native'; import { Surface, Text, Palette, List, IconButton } from 'react-native-paper'; import type { Elevation } from 'react-native-paper'; @@ -12,19 +12,10 @@ const AnimatedSurface = () => { const [index, setIndex] = React.useState(3); const level = elevationLevels[index]; - const elevation = React.useRef(new Animated.Value(level)).current; - - React.useEffect(() => { - Animated.timing(elevation, { - toValue: level, - duration: 250, - useNativeDriver: false, - }).start(); - }, [elevation, level]); return ( - + {`Elevation ${level}`} @@ -49,7 +40,13 @@ const SurfaceExample = () => { const elevationValues: Elevation[] = [0, 1, 2, 3, 4, 5]; const renderSurface = (index: Elevation, mode: 'flat' | 'elevated') => ( - + {`Elevation ${index}`} ); @@ -93,10 +90,14 @@ const SurfaceExample = () => { - + Top - + Bottom @@ -121,7 +122,6 @@ const styles = StyleSheet.create({ surface: { height: 120, width: 120, - borderRadius: 8, alignItems: 'center', justifyContent: 'center', }, @@ -153,6 +153,9 @@ const styles = StyleSheet.create({ }, verticalSurface: { height: '48%', + }, + verticalSurfaceContent: { + flex: 1, justifyContent: 'center', }, diff --git a/jest/testSetup.js b/jest/testSetup.js index c00e611084..1033126c45 100644 --- a/jest/testSetup.js +++ b/jest/testSetup.js @@ -4,13 +4,10 @@ jest.useFakeTimers(); jest.mock('react-native-safe-area-context', () => mockSafeAreaContext); -jest.mock('react-native-worklets', () => - require('react-native-worklets/lib/module/mock') -); - -jest.mock('react-native-reanimated', () => - require('react-native-reanimated/mock') -); +jest.mock('react-native-worklets', () => ({ + ...require('react-native-worklets/lib/module/mock'), + isUIRuntime: () => false, +})); jest.mock('@react-native-vector-icons/material-design-icons', () => { const React = require('react'); diff --git a/src/components/Appbar/Appbar.tsx b/src/components/Appbar/Appbar.tsx index 8ef9965819..69308ebc8a 100644 --- a/src/components/Appbar/Appbar.tsx +++ b/src/components/Appbar/Appbar.tsx @@ -1,19 +1,22 @@ import * as React from 'react'; -import { Animated, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import type { ColorValue, StyleProp, ViewProps, ViewStyle } from 'react-native'; import AppbarContent from './AppbarContent'; import { getAppbarBackgroundColor, + getAppbarBorders, modeAppbarHeight, renderAppbarContent, filterAppbarActions, } from './utils'; import type { AppbarModes, AppbarChildProps } from './utils'; import { useInternalTheme } from '../../core/theming'; -import type { Elevation, ThemeProp } from '../../types'; +import type { ThemeProp } from '../../types'; import Surface from '../Surface'; +const APPBAR_HORIZONTAL_PADDING = 4; + export type Props = Omit, 'style'> & { /** * Whether the background color is a dark color. A dark appbar will render light text and vice-versa. @@ -51,7 +54,7 @@ export type Props = Omit, 'style'> & { * @optional */ theme?: ThemeProp; - style?: Animated.WithAnimatedValue>; + style?: StyleProp; }; /** @@ -145,30 +148,26 @@ const Appbar = ({ dark, style, mode = 'small', - elevated, + elevated = false, safeAreaInsets, theme: themeOverrides, ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); const flattenedStyle = StyleSheet.flatten(style); - const { - backgroundColor: customBackground, - elevation = elevated ? 2 : 0, - ...restStyle - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - } = (flattenedStyle || {}) as Exclude & { - elevation?: Elevation; + const { backgroundColor: customBackground, ...restStyle } = (flattenedStyle || + {}) as Exclude & { backgroundColor?: ColorValue; }; const backgroundColor = getAppbarBackgroundColor( theme, - elevation, - customBackground, - elevated + elevated, + customBackground ); + const borderRadius = getAppbarBorders(restStyle); + const isMode = (modeToCompare: AppbarModes) => { return mode === modeToCompare; }; @@ -210,23 +209,27 @@ const Appbar = ({ const insets = { paddingBottom: safeAreaInsets?.bottom, paddingTop: safeAreaInsets?.top, - paddingLeft: safeAreaInsets?.left, - paddingRight: safeAreaInsets?.right, + paddingLeft: safeAreaInsets?.left + ? safeAreaInsets.left + APPBAR_HORIZONTAL_PADDING + : APPBAR_HORIZONTAL_PADDING, + paddingRight: safeAreaInsets?.right + ? safeAreaInsets.right + APPBAR_HORIZONTAL_PADDING + : APPBAR_HORIZONTAL_PADDING, }; return ( {shouldAddLeftSpacing ? : null} @@ -308,7 +311,6 @@ const styles = StyleSheet.create({ appbar: { flexDirection: 'row', alignItems: 'center', - paddingHorizontal: 4, }, v3Spacing: { width: 52, diff --git a/src/components/Appbar/AppbarAction.tsx b/src/components/Appbar/AppbarAction.tsx index 400271a988..c6d68a362e 100644 --- a/src/components/Appbar/AppbarAction.tsx +++ b/src/components/Appbar/AppbarAction.tsx @@ -1,11 +1,7 @@ import * as React from 'react'; -import type { - Animated, - ColorValue, - StyleProp, - View, - ViewStyle, -} from 'react-native'; +import type { ColorValue, StyleProp, View, ViewStyle } from 'react-native'; + +import type { AnimatedStyle } from 'react-native-reanimated'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../types'; @@ -43,7 +39,7 @@ export type Props = React.ComponentPropsWithoutRef & { * Whether it's the leading button. Note: If `Appbar.BackAction` is present, it will be rendered before any `isLeading` icons. */ isLeading?: boolean; - style?: Animated.WithAnimatedValue>; + style?: StyleProp>; ref?: React.Ref; /** * @optional diff --git a/src/components/Appbar/AppbarBackAction.tsx b/src/components/Appbar/AppbarBackAction.tsx index 2835c27c91..c4249bd18a 100644 --- a/src/components/Appbar/AppbarBackAction.tsx +++ b/src/components/Appbar/AppbarBackAction.tsx @@ -1,6 +1,5 @@ import * as React from 'react'; import type { - Animated, ColorValue, GestureResponderEvent, StyleProp, @@ -8,6 +7,8 @@ import type { ViewStyle, } from 'react-native'; +import type { AnimatedStyle } from 'react-native-reanimated'; + import type { $Omit } from './../../types'; import AppbarAction from './AppbarAction'; import AppbarBackIcon from './AppbarBackIcon'; @@ -36,7 +37,7 @@ export type Props = $Omit< * Function to execute on press. */ onPress?: (e: GestureResponderEvent) => void; - style?: Animated.WithAnimatedValue>; + style?: StyleProp>; ref?: React.Ref; }; diff --git a/src/components/Appbar/AppbarHeader.tsx b/src/components/Appbar/AppbarHeader.tsx index 0252cc89ba..f9015d84c8 100644 --- a/src/components/Appbar/AppbarHeader.tsx +++ b/src/components/Appbar/AppbarHeader.tsx @@ -1,22 +1,17 @@ import * as React from 'react'; -import { Animated, Platform, StyleSheet, View } from 'react-native'; +import { Platform, StyleSheet } from 'react-native'; import type { ColorValue, StyleProp, ViewStyle } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Appbar } from './Appbar'; -import { - getAppbarBackgroundColor, - modeAppbarHeight, - getAppbarBorders, -} from './utils'; +import { getAppbarBackgroundColor, modeAppbarHeight } from './utils'; import { useInternalTheme } from '../../core/theming'; -import { shadow } from '../../theme/tokens/sys/elevation'; import type { ThemeProp } from '../../types'; export type Props = Omit< React.ComponentProps, - 'safeAreaInsets' + 'safeAreaInsets' | 'style' > & { /** * Whether the background color is a dark color. A dark header will render light text and vice-versa. @@ -52,7 +47,7 @@ export type Props = Omit< * @optional */ theme?: ThemeProp; - style?: Animated.WithAnimatedValue>; + style?: StyleProp; }; /** @@ -100,64 +95,53 @@ const AppbarHeader = ({ const flattenedStyle = StyleSheet.flatten(style); const { height = modeAppbarHeight[mode], - elevation = elevated ? 2 : 0, backgroundColor: customBackground, zIndex = elevated ? 1 : 0, ...restStyle // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion } = (flattenedStyle || {}) as Exclude & { height?: number; - elevation?: number; backgroundColor?: ColorValue; zIndex?: number; }; - const borderRadius = getAppbarBorders(restStyle); - const backgroundColor = getAppbarBackgroundColor( theme, - elevation, - customBackground, - elevated + elevated, + customBackground ); const { top, left, right } = useSafeAreaInsets(); + const topInset = statusBarHeight ?? top; + const horizontalInset = Math.max(left, right); return ( - - - + safeAreaInsets={{ + top: topInset, + left: horizontalInset, + right: horizontalInset, + }} + dark={dark} + elevated={elevated} + {...rest} + mode={mode} + theme={theme} + /> ); }; AppbarHeader.displayName = 'Appbar.Header'; -const styles = StyleSheet.create({ - appbar: { - elevation: 0, - }, -}); - export default AppbarHeader; // @component-docs ignore-next-line diff --git a/src/components/Appbar/utils.ts b/src/components/Appbar/utils.ts index a6ea546b34..151485a589 100644 --- a/src/components/Appbar/utils.ts +++ b/src/components/Appbar/utils.ts @@ -1,6 +1,6 @@ import React from 'react'; import type { ColorValue, StyleProp, ViewStyle } from 'react-native'; -import { StyleSheet, Animated } from 'react-native'; +import { StyleSheet } from 'react-native'; import { white } from '../../theme/colors'; import type { InternalTheme, ThemeProp } from '../../types'; @@ -23,9 +23,8 @@ const borderStyleProperties = [ export const getAppbarBackgroundColor = ( theme: InternalTheme, - _elevation: number, - customBackground?: ColorValue, - elevated?: boolean + elevated: boolean, + customBackground?: ColorValue ) => { const { colors } = theme; if (customBackground) { @@ -54,19 +53,14 @@ export const getAppbarColor = ({ return undefined; }; -export const getAppbarBorders = ( - style: - | Animated.Value - | Animated.AnimatedInterpolation - | Animated.WithAnimatedObject -) => { - const borders: Record = {}; +export const getAppbarBorders = (style: ViewStyle) => { + let borders: ViewStyle = {}; for (const property of borderStyleProperties) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const value = style[property as keyof typeof style]; - if (value) { - borders[property] = value; + const value = style[property]; + + if (typeof value === 'number' || typeof value === 'string') { + borders = { ...borders, [property]: value }; } } diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx index 869adf6820..4567055f6b 100644 --- a/src/components/Badge.tsx +++ b/src/components/Badge.tsx @@ -1,7 +1,7 @@ import type { StyleProp, TextProps, TextStyle } from 'react-native'; import { StyleSheet } from 'react-native'; -import Animated from 'react-native-reanimated'; +import Animated, { type AnimatedStyle } from 'react-native-reanimated'; import { useInternalTheme } from '../core/theming'; import { cornerFull } from '../theme/tokens/sys/shape'; @@ -21,7 +21,7 @@ export type Props = TextProps & { * Content of the `Badge`. */ children?: string | number; - style?: StyleProp; + style?: StyleProp>; /** * @optional */ diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx index f994396931..13599d3130 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -1,8 +1,22 @@ import * as React from 'react'; -import { Animated, StyleSheet, View } from 'react-native'; -import type { StyleProp, ViewStyle } from 'react-native'; -import type { LayoutChangeEvent } from 'react-native'; +import { StyleSheet, View } from 'react-native'; +import type { + LayoutChangeEvent, + StyleProp, + ViewProps, + ViewStyle, +} from 'react-native'; +import Animated, { + Easing, + interpolate, + ReduceMotion, + useAnimatedStyle, + useSharedValue, + withTiming, + type AnimatedStyle, +} from 'react-native-reanimated'; +import { scheduleOnRN } from 'react-native-worklets'; import useLatestCallback from 'use-latest-callback'; import Button from './Button/Button'; @@ -11,11 +25,13 @@ import type { IconSource } from './Icon'; import Surface from './Surface'; import Text from './Typography/Text'; import { useInternalTheme } from '../core/theming'; -import type { $Omit, $RemoveChildren, ThemeProp } from '../types'; +import type { $RemoveChildren, ThemeProp } from '../types'; const DEFAULT_MAX_WIDTH = 960; -export type Props = $Omit<$RemoveChildren, 'mode'> & { +type AnimationFinishedCallback = (result: { finished: boolean }) => void; + +export type Props = Omit & { /** * Whether banner is currently visible. */ @@ -51,12 +67,12 @@ export type Props = $Omit<$RemoveChildren, 'mode'> & { * @supported Available in v5.x with theme version 3 * Changes Banner shadow and background on iOS and Android. */ - elevation?: 0 | 1 | 2 | 3 | 4 | 5 | Animated.Value; + elevation?: 0 | 1 | 2 | 3 | 4 | 5; /** * Specifies the largest possible scale a text font can reach. */ maxFontSizeMultiplier?: number; - style?: Animated.WithAnimatedValue>; + style?: StyleProp>; ref?: React.RefObject; /** * @optional @@ -66,12 +82,12 @@ export type Props = $Omit<$RemoveChildren, 'mode'> & { * @optional * Optional callback that will be called after the opening animation finished running normally */ - onShowAnimationFinished?: Animated.EndCallback; + onShowAnimationFinished?: AnimationFinishedCallback; /** * @optional * Optional callback that will be called after the closing animation finished running normally */ - onHideAnimationFinished?: Animated.EndCallback; + onHideAnimationFinished?: AnimationFinishedCallback; }; /** @@ -134,9 +150,9 @@ const Banner = ({ }: Props) => { const theme = useInternalTheme(themeOverrides); const { colors } = theme; - const { current: position } = React.useRef( - new Animated.Value(visible ? 1 : 0) - ); + + const position = useSharedValue(visible ? 1 : 0); + const [layout, setLayout] = React.useState<{ height: number; measured: boolean; @@ -149,30 +165,33 @@ const Banner = ({ const hideCallback = useLatestCallback(onHideAnimationFinished); const { scale } = theme.animation; - - const opacity = position.interpolate({ - inputRange: [0, 0.1, 1], - outputRange: [0, 1, 1], - }); + const animationDuration = (visible ? 250 : 200) * scale; React.useEffect(() => { - if (visible) { - // show - Animated.timing(position, { - duration: 250 * scale, - toValue: 1, - useNativeDriver: false, - }).start(showCallback); - } else { - // hide - Animated.timing(position, { - duration: 200 * scale, - toValue: 0, - useNativeDriver: false, - }).start(hideCallback); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [visible, position, scale]); + const callback = visible ? showCallback : hideCallback; + + position.value = withTiming( + visible ? 1 : 0, + { + duration: animationDuration, + easing: Easing.inOut(Easing.ease), + reduceMotion: ReduceMotion.Never, + }, + (finished) => scheduleOnRN(callback, { finished: finished ?? false }) + ); + }, [animationDuration, hideCallback, position, showCallback, visible]); + + const surfaceStyle = useAnimatedStyle(() => ({ + opacity: interpolate(position.value, [0, 0.1, 1], [0, 1, 1]), + })); + + const spacerStyle = useAnimatedStyle(() => ({ + height: position.value * layout.height, + })); + + const contentAnimationStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: (position.value - 1) * layout.height }], + })); const handleLayout = ({ nativeEvent }: LayoutChangeEvent) => { const { height } = nativeEvent.layout; @@ -186,29 +205,22 @@ const Banner = ({ // Once we have the height, we apply the height to the spacer and switch the banner to position: absolute // We need this because we need to move the content below as if banner's height was being animated // However we can't animated banner's height directly as it'll also resize the content inside - const height = Animated.multiply(position, layout.height); - - const translateY = Animated.multiply( - Animated.add(position, -1), - layout.height - ); return ( - + ({ backgroundColor: customBackground, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion } = (StyleSheet.flatten(style) || {}) as { - elevation?: number; backgroundColor?: ColorValue; }; @@ -462,10 +461,15 @@ const BottomNavigationBar = ({ bottom: safeAreaInsets?.bottom ?? bottom, }; + const pointerEvents = layout.measured + ? keyboardHidesNavigationBar && keyboardVisible + ? 'none' + : 'auto' + : 'none'; + return ( - ({ : null, style, ]} - pointerEvents={ - layout.measured - ? keyboardHidesNavigationBar && keyboardVisible - ? 'none' - : 'auto' - : 'none' - } - onLayout={onLayout} - container + pointerEvents={pointerEvents} > - - - {routes.map((route, index) => { - const focused = navigationState.index === index; - const active = tabsAnims[index]; - - // Move down the icon to account for no-label in shifting and smaller label in non-shifting. - const translateY = labeled - ? shifting + + {routes.map((route, index) => { + const focused = navigationState.index === index; + const active = tabsAnims[index]; + + // Move down the icon to account for no-label in shifting and smaller label in non-shifting. + const translateY = labeled + ? shifting + ? active.interpolate({ + inputRange: [0, 1], + outputRange: [7, 0], + }) + : 0 + : 7; + + // We render the active icon and label on top of inactive ones and cross-fade them on change. + // This trick gives the illusion that we are animating between active and inactive colors. + // This is to ensure that we can use native driver, as colors cannot be animated with native driver. + const activeOpacity = active; + + const inactiveOpacity = active.interpolate({ + inputRange: [0, 1], + outputRange: [1, 0], + }); + + const v3ActiveOpacity = focused ? 1 : 0; + + const v3InactiveOpacity = shifting + ? inactiveOpacity + : focused + ? 0 + : 1; + + // Scale horizontally the outline pill + const outlineScale = focused ? active.interpolate({ inputRange: [0, 1], - outputRange: [7, 0], + outputRange: [0.5, 1], }) - : 0 - : 7; - - // We render the active icon and label on top of inactive ones and cross-fade them on change. - // This trick gives the illusion that we are animating between active and inactive colors. - // This is to ensure that we can use native driver, as colors cannot be animated with native driver. - const activeOpacity = active; - const inactiveOpacity = active.interpolate({ - inputRange: [0, 1], - outputRange: [1, 0], - }); - - const v3ActiveOpacity = focused ? 1 : 0; - const v3InactiveOpacity = shifting - ? inactiveOpacity - : focused - ? 0 - : 1; - - // Scale horizontally the outline pill - const outlineScale = focused - ? active.interpolate({ - inputRange: [0, 1], - outputRange: [0.5, 1], - }) - : 0; - - const badge = getBadge({ route }); - - const activeLabelColor = getLabelColor({ - tintColor: activeTintColor, - hasColor: Boolean(activeColor), - focused, - theme, - }); - - const inactiveLabelColor = getLabelColor({ - tintColor: inactiveTintColor, - hasColor: Boolean(inactiveColor), - focused, - theme, - }); - - const badgeStyle = { - top: typeof badge === 'boolean' ? 4 : 2, - right: - badge != null && typeof badge !== 'boolean' - ? String(badge).length * -2 - : 0, - }; - - const isLegacyOrV3Shifting = shifting && labeled; - - const font = theme.fonts.labelMedium; - - return renderTouchable({ - key: route.key, - route, - borderless: true, - centered: true, - rippleColor: 'transparent', - onPress: () => onTabPress(eventForIndex(index)), - onLongPress: () => onTabLongPress?.(eventForIndex(index)), - testID: getTestID({ route }), - 'aria-label': getAccessibilityLabel({ route }), - role: Platform.OS === 'ios' ? 'button' : 'tab', - 'aria-selected': focused, - style: [styles.item, styles.v3Item], - children: ( - - onTabPress(eventForIndex(index)), + onLongPress: () => onTabLongPress?.(eventForIndex(index)), + testID: getTestID({ route }), + 'aria-label': getAccessibilityLabel({ route }), + role: Platform.OS === 'ios' ? 'button' : 'tab', + 'aria-selected': focused, + style: [styles.item, styles.v3Item], + children: ( + - {focused && ( - - )} - - {renderIcon ? ( - renderIcon({ - route, - focused: true, - color: activeTintColor, - }) - ) : ( - - )} - - {renderIcon ? ( - renderIcon({ - route, - focused: false, - color: inactiveTintColor, - }) - ) : ( - )} - - - {typeof badge === 'boolean' ? ( - - ) : ( - {badge} - )} - - - {labeled ? ( - ({ }, ]} > - {renderLabel ? ( - renderLabel({ + {renderIcon ? ( + renderIcon({ route, focused: true, - color: activeLabelColor, + color: activeTintColor, }) ) : ( - - {getLabelText({ route })} - + )} - {shifting ? null : ( + + {renderIcon ? ( + renderIcon({ + route, + focused: false, + color: inactiveTintColor, + }) + ) : ( + + )} + + + {typeof badge === 'boolean' ? ( + + ) : ( + {badge} + )} + + + {labeled ? ( + {renderLabel ? ( renderLabel({ route, - focused: false, - color: inactiveLabelColor, + focused: true, + color: activeLabelColor, }) ) : ( ({ )} - )} - - ) : null} - - ), - }); - })} - - - + {shifting ? null : ( + + {renderLabel ? ( + renderLabel({ + route, + focused: false, + color: inactiveLabelColor, + }) + ) : ( + + {getLabelText({ route })} + + )} + + )} + + ) : null} + + ), + }); + })} + + + + ); }; diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index bfc7782d66..a3c377b248 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Animated, Platform, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import type { ColorValue, GestureResponderEvent, @@ -7,13 +7,16 @@ import type { Role, StyleProp, TextStyle, + ViewProps, ViewStyle, } from 'react-native'; +import type { AnimatedStyle } from 'react-native-reanimated'; + import { getButtonColors, getButtonTouchableRippleStyle } from './utils'; import type { ButtonMode } from './utils'; import { useInternalTheme } from '../../core/theming'; -import type { $Omit, ThemeProp } from '../../types'; +import type { ThemeProp } from '../../types'; import hasTouchHandler from '../../utils/hasTouchHandler'; import { splitStyles } from '../../utils/splitStyles'; import ActivityIndicator from '../ActivityIndicator'; @@ -24,7 +27,7 @@ import TouchableRipple from '../TouchableRipple/TouchableRipple'; import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; import Text from '../Typography/Text'; -export type Props = $Omit, 'mode'> & { +export type Props = Omit & { /** * Mode of the button. You can change the mode to adjust the styling to give it desired emphasis. * - `text` - flat button without background or outline, used for the lowest priority actions, especially when presenting multiple options. @@ -122,7 +125,7 @@ export type Props = $Omit, 'mode'> & { * Sets additional distance outside of element in which a press can be detected. */ hitSlop?: TouchableRippleProps['hitSlop']; - style?: Animated.WithAnimatedValue>; + style?: StyleProp>; /** * Style for the button text. */ @@ -192,15 +195,15 @@ const Button = ({ ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); + const isMode = React.useCallback( (modeToCompare: ButtonMode) => { return mode === modeToCompare; }, [mode] ); - const { animation } = theme; + const uppercase = uppercaseProp ?? false; - const isWeb = Platform.OS === 'web'; const hasPassedTouchHandler = hasTouchHandler({ onPress, @@ -213,43 +216,27 @@ const Button = ({ const initialElevation = 1; const activeElevation = 2; - const { current: elevation } = React.useRef( - new Animated.Value(isElevationEntitled ? initialElevation : 0) - ); + const [pressed, setPressed] = React.useState(false); - React.useEffect(() => { - // Workaround not to call setValue on Animated.Value, because it breaks styles. - // https://github.com/callstack/react-native-paper/issues/4559 - Animated.timing(elevation, { - toValue: isElevationEntitled ? initialElevation : 0, - duration: 0, - useNativeDriver: true, - }); - }, [isElevationEntitled, elevation, initialElevation]); + const elevation = isElevationEntitled + ? pressed + ? activeElevation + : initialElevation + : 0; const handlePressIn = (e: GestureResponderEvent) => { onPressIn?.(e); + if (isMode('elevated')) { - const { scale } = animation; - Animated.timing(elevation, { - toValue: activeElevation, - duration: 200 * scale, - useNativeDriver: - isWeb || Platform.constants.reactNativeVersion.minor <= 72, - }).start(); + setPressed(true); } }; const handlePressOut = (e: GestureResponderEvent) => { onPressOut?.(e); + if (isMode('elevated')) { - const { scale } = animation; - Animated.timing(elevation, { - toValue: initialElevation, - duration: 150 * scale, - useNativeDriver: - isWeb || Platform.constants.reactNativeVersion.minor <= 72, - }).start(); + setPressed(false); } }; @@ -321,9 +308,18 @@ const Button = ({ {...rest} ref={ref} testID={`${testID}-container`} - style={[styles.button, compact && styles.compact, buttonStyle, style]} + backgroundColor={buttonStyle.backgroundColor} + {...touchableStyle} + style={[ + styles.button, + compact && styles.compact, + { + borderColor, + borderWidth, + }, + style, + ]} elevation={elevation} - container > {backgroundOpacity < 1 && ( , 'mode'> & { +export type Props = Omit & { /** * Mode of the Card. * - `elevated` - Card with elevation. @@ -73,12 +77,12 @@ export type Props = $Omit, 'mode'> & { /** * Changes Card shadow and background on iOS and Android. */ - elevation?: 0 | 1 | 2 | 3 | 4 | 5 | Animated.Value; + elevation?: 0 | 1 | 2 | 3 | 4 | 5; /** * Style of card's inner content. */ contentStyle?: StyleProp; - style?: Animated.WithAnimatedValue>; + style?: StyleProp>; /** * @optional */ @@ -91,6 +95,10 @@ export type Props = $Omit, 'mode'> & { * Pass down accessible from card props to touchable */ accessible?: boolean; + /** + * Reference to the card container. + */ + ref?: React.Ref; }; /** @@ -141,6 +149,7 @@ const Card = ({ ...rest }: (OutlinedCardProps | ElevatedCardProps | ContainedCardProps) & Props) => { const theme = useInternalTheme(themeOverrides); + const isMode = React.useCallback( (modeToCompare: Mode) => { return cardMode === modeToCompare; @@ -155,34 +164,23 @@ const Card = ({ onPressOut, }); - const { current: elevation } = React.useRef( - new Animated.Value(cardElevation) - ); - const { animation } = theme; - - const animationDuration = 150 * animation.scale; - - const runElevationAnimation = (pressType: HandlePressType) => { - if (isMode('contained')) { - return; - } - - const isPressTypeIn = pressType === 'in'; - Animated.timing(elevation, { - toValue: isPressTypeIn ? 2 : cardElevation, - duration: animationDuration, - useNativeDriver: false, - }).start(); - }; + const [pressed, setPressed] = React.useState(false); + const elevation = isMode('elevated') ? (pressed ? 2 : cardElevation) : 0; const handlePressIn = useLatestCallback((e: GestureResponderEvent) => { onPressIn?.(e); - runElevationAnimation('in'); + + if (isMode('elevated')) { + setPressed(true); + } }); const handlePressOut = useLatestCallback((e: GestureResponderEvent) => { onPressOut?.(e); - runElevationAnimation('out'); + + if (isMode('elevated')) { + setPressed(false); + } }); const total = React.Children.count(children); @@ -232,15 +230,12 @@ const Card = ({ return ( {isMode('outlined') && ( diff --git a/src/components/Chip/Chip.tsx b/src/components/Chip/Chip.tsx index b6482e9209..beeaaa29b3 100644 --- a/src/components/Chip/Chip.tsx +++ b/src/components/Chip/Chip.tsx @@ -1,21 +1,23 @@ import * as React from 'react'; -import { Animated, Platform, StyleSheet, Pressable, View } from 'react-native'; +import { Platform, StyleSheet, Pressable, View } from 'react-native'; import type { ColorValue, GestureResponderEvent, PressableAndroidRippleConfig, StyleProp, TextStyle, + ViewProps, ViewStyle, } from 'react-native'; +import type { AnimatedStyle } from 'react-native-reanimated'; import useLatestCallback from 'use-latest-callback'; import { getChipColors } from './helpers'; import type { ChipAvatarProps } from './helpers'; import { useInternalTheme } from '../../core/theming'; import { white } from '../../theme/colors'; -import type { $Omit, EllipsizeProp, ThemeProp } from '../../types'; +import type { EllipsizeProp, ThemeProp } from '../../types'; import hasTouchHandler from '../../utils/hasTouchHandler'; import type { IconSource } from '../Icon'; import Icon from '../Icon'; @@ -25,7 +27,7 @@ import TouchableRipple from '../TouchableRipple/TouchableRipple'; import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; import Text from '../Typography/Text'; -export type Props = $Omit, 'mode'> & { +export type Props = Omit & { /** * Mode of the chip. * - `flat` - flat chip without outline. @@ -123,7 +125,7 @@ export type Props = $Omit, 'mode'> & { * Style of chip's text */ textStyle?: StyleProp; - style?: Animated.WithAnimatedValue>; + style?: StyleProp>; /** * Sets additional distance outside of element in which a press can be detected. */ @@ -144,6 +146,10 @@ export type Props = $Omit, 'mode'> & { * Specifies the largest possible scale a text font can reach. */ maxFontSizeMultiplier?: number; + /** + * Reference to the chip container. + */ + ref?: React.Ref; }; /** @@ -201,11 +207,9 @@ const Chip = ({ ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); - const isWeb = Platform.OS === 'web'; - const { current: elevation } = React.useRef( - new Animated.Value(elevated ? 1 : 0) - ); + const [pressed, setPressed] = React.useState(false); + const elevation = elevated ? (pressed ? 2 : 1) : 0; const hasPassedTouchHandler = hasTouchHandler({ onPress, @@ -217,25 +221,13 @@ const Chip = ({ const isOutlined = mode === 'outlined'; const handlePressIn = useLatestCallback((e: GestureResponderEvent) => { - const { scale } = theme.animation; onPressIn?.(e); - Animated.timing(elevation, { - toValue: elevated ? 2 : 0, - duration: 200 * scale, - useNativeDriver: - isWeb || Platform.constants.reactNativeVersion.minor <= 72, - }).start(); + setPressed(true); }); const handlePressOut = useLatestCallback((e: GestureResponderEvent) => { - const { scale } = theme.animation; onPressOut?.(e); - Animated.timing(elevation, { - toValue: elevated ? 1 : 0, - duration: 150 * scale, - useNativeDriver: - isWeb || Platform.constants.reactNativeVersion.minor <= 72, - }).start(); + setPressed(false); }); const opacity = 0.38; @@ -265,6 +257,7 @@ const Chip = ({ const elevationStyle = elevation; const multiplier = compact ? 1.5 : 2; + const labelSpacings = { marginRight: onClose ? 0 : 8 * multiplier, marginLeft: @@ -272,30 +265,25 @@ const Chip = ({ ? 4 * multiplier : 8 * multiplier, }; + const contentSpacings = { paddingRight: onClose ? 34 : 0, }; + const labelTextStyle = { color: textColor, ...theme.fonts.labelLarge, }; + return ( >; + style?: StyleProp>; /** * @optional */ @@ -46,7 +47,7 @@ export type Props = { testID?: string; }; -const DIALOG_ELEVATION: number = 24; +const DIALOG_ELEVATION: Elevation = 3; /** * Dialogs inform users about a specific task and may contain critical information, require decisions, or involve multiple tasks. @@ -99,6 +100,7 @@ const Dialog = ({ testID, }: Props) => { const { right, left } = useSafeAreaInsets(); + const theme = useInternalTheme(themeOverrides); const borderRadius = theme.shapes.corner.extraLarge; @@ -110,10 +112,11 @@ const Dialog = ({ dismissableBackButton={dismissableBackButton} onDismiss={onDismiss} visible={visible} + contentBackgroundColor={backgroundColor} + contentBorderRadius={borderRadius} + contentElevation={DIALOG_ELEVATION} contentContainerStyle={[ { - borderRadius, - backgroundColor, marginHorizontal: Math.max(left, right, 26), }, styles.container, @@ -158,7 +161,6 @@ const styles = StyleSheet.create({ * dialog (44 pixel from the top and bottom) it won't be dismissed. */ marginVertical: Platform.OS === 'android' ? 44 : 0, - elevation: DIALOG_ELEVATION, justifyContent: 'flex-start', }, }); diff --git a/src/components/FAB/Extended.tsx b/src/components/FAB/Extended.tsx index 06a67cab1f..ff3c500545 100644 --- a/src/components/FAB/Extended.tsx +++ b/src/components/FAB/Extended.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { StyleSheet, View } from 'react-native'; +import { Platform, StyleSheet, View } from 'react-native'; import type { ColorValue, GestureResponderEvent, @@ -8,14 +8,15 @@ import type { ViewStyle, } from 'react-native'; -import Reanimated, { +import Animated, { + type AnimatedStyle, measure, useAnimatedRef, useAnimatedStyle, useSharedValue, withSpring, } from 'react-native-reanimated'; -import { scheduleOnUI } from 'react-native-worklets'; +import { isUIRuntime, scheduleOnUI } from 'react-native-worklets'; import Shell from './Shell'; import type { Size, Variant } from './tokens'; @@ -101,7 +102,7 @@ export type Props = { * Style for positioning the FAB. The visual treatment (size, shape, color) * is driven by `variant` and `size`. */ - style?: StyleProp; + style?: StyleProp>; /** * TestID used for testing purposes. */ @@ -177,7 +178,7 @@ const Extended = ({ const dimensions = getDimensions({ theme, size }); - const offscreenLabelRef = useAnimatedRef(); + const offscreenLabelRef = useAnimatedRef(); const widthValue = useSharedValue(dimensions.width); const labelOpacity = useSharedValue(expanded ? 1 : 0); @@ -190,15 +191,20 @@ const Extended = ({ iconLabelGap, trailing, } = dimensions; + const targetOpacity = expanded ? 1 : 0; if (reduceMotion) { scheduleOnUI(() => { 'worklet'; - const m = measure(offscreenLabelRef); - const lw = m?.width ?? 0; + + const labelWidth = + Platform.OS === 'web' || isUIRuntime() + ? (measure(offscreenLabelRef)?.width ?? 0) + : 0; + widthValue.value = expanded - ? leading + iconSize + iconLabelGap + lw + trailing + ? leading + iconSize + iconLabelGap + labelWidth + trailing : collapsedWidth; labelOpacity.value = targetOpacity; }); @@ -218,9 +224,15 @@ const Extended = ({ scheduleOnUI(() => { 'worklet'; - const m = measure(offscreenLabelRef); - const lw = m?.width ?? 0; - const expandedWidth = leading + iconSize + iconLabelGap + lw + trailing; + + const labelWidth = + Platform.OS === 'web' || isUIRuntime() + ? (measure(offscreenLabelRef)?.width ?? 0) + : 0; + + const expandedWidth = + leading + iconSize + iconLabelGap + labelWidth + trailing; + widthValue.value = withSpring( expanded ? expandedWidth : collapsedWidth, widthSpring @@ -267,7 +279,7 @@ const Extended = ({ testID={testID} theme={themeOverrides} /> - {label} - + ); }; diff --git a/src/components/FAB/FAB.tsx b/src/components/FAB/FAB.tsx index fa4de1288c..fac6460c6a 100644 --- a/src/components/FAB/FAB.tsx +++ b/src/components/FAB/FAB.tsx @@ -8,6 +8,8 @@ import type { ViewStyle, } from 'react-native'; +import type { AnimatedStyle } from 'react-native-reanimated'; + import Shell from './Shell'; import type { Size, Variant } from './tokens'; import type { ThemeProp } from '../../types'; @@ -73,7 +75,7 @@ export type Props = { * Style for positioning the FAB. The visual treatment (size, shape, color) * is driven by `variant` and `size`. */ - style?: StyleProp; + style?: StyleProp>; /** * TestID used for testing purposes. */ diff --git a/src/components/FAB/Shell.tsx b/src/components/FAB/Shell.tsx index 2d2d60e4cd..b9f6279194 100644 --- a/src/components/FAB/Shell.tsx +++ b/src/components/FAB/Shell.tsx @@ -8,9 +8,10 @@ import type { ViewStyle, } from 'react-native'; -import Reanimated, { +import Animated, { useAnimatedStyle, useSharedValue, + withSpring, } from 'react-native-reanimated'; import type { SharedValue } from 'react-native-reanimated'; import type { AnimatedStyle } from 'react-native-reanimated'; @@ -24,12 +25,14 @@ import { } from './tokens'; import type { Size, Variant } from './tokens'; import { useFocusRing } from './useFocusRing'; -import { useVisibility } from './useVisibility'; import { getDimensions, resolveColors } from './utils'; import { useInternalTheme } from '../../core/theming'; +import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; +import { toRawSpring } from '../../theme/tokens/sys/motion'; import type { ShapeToken } from '../../theme/utils/shape'; import type { Elevation, ThemeProp } from '../../types'; import type { IconSource } from '../Icon'; +import Surface from '../Surface'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; export type ShellProps = { @@ -159,7 +162,7 @@ export type ShellProps = { * Outer-positioning style. Visual treatment (size, shape, color) comes from * `variant` and `size`. */ - style?: StyleProp; + style?: StyleProp>; /** * TestID used for testing purposes. */ @@ -174,7 +177,7 @@ export type ShellProps = { /** * Internal shell used by every FAB-flavored component (regular, Extended, * morphing menu trigger). Owns the outer container, ripple, clip, and the - * visibility animation (scale + alpha + shadow). Consumers that need to + * visibility animation (scale + alpha). Consumers that need to * animate the outer's width/height/borderRadius pass shared values; the * static size-driven defaults are used otherwise. * @@ -225,11 +228,30 @@ const Shell = ({ [theme, variant, containerColor, contentColor] ); - const { scale, alpha, shadowStyle } = useVisibility({ - visible, - theme, - elevation, - }); + const reduceMotion = useReduceMotion(); + + const scale = useSharedValue(visible ? 1 : 0); + const alpha = useSharedValue(visible ? 1 : 0); + + React.useEffect(() => { + const target = visible ? 1 : 0; + + if (reduceMotion) { + scale.value = target; + alpha.value = target; + return; + } + + scale.value = withSpring( + target, + toRawSpring(theme.motion.spring.fast.spatial) + ); + + alpha.value = withSpring( + target, + toRawSpring(theme.motion.spring.fast.effects) + ); + }, [visible, theme, reduceMotion, scale, alpha]); // Fallback shared values track the static size-driven dimensions. Consumers // that don't supply their own animated shared values get these. Keeping @@ -238,6 +260,7 @@ const Shell = ({ const fallbackWidth = useSharedValue(dimensions.width); const fallbackHeight = useSharedValue(dimensions.height); const fallbackBorderRadius = useSharedValue(dimensions.borderRadius); + React.useEffect(() => { fallbackWidth.value = dimensions.width; fallbackHeight.value = dimensions.height; @@ -262,10 +285,8 @@ const Shell = ({ opacity: alpha.value, width: width.value, height: height.value, - borderRadius: borderRadius.value, - backgroundColor: containerBg, }), - [width, height, borderRadius, containerBg] + [width, height] ); const clipStyle = useAnimatedStyle( @@ -277,6 +298,7 @@ const Shell = ({ ); const { focusedSV, onFocus, onBlur } = useFocusRing(); + const focusRingStyle = useAnimatedStyle( () => ({ opacity: focusedSV.value ? 1 : 0, @@ -286,18 +308,21 @@ const Shell = ({ ); return ( - - + {overlay} )} - - + - + ); }; diff --git a/src/components/FAB/useVisibility.ts b/src/components/FAB/useVisibility.ts deleted file mode 100644 index f6c4b2e645..0000000000 --- a/src/components/FAB/useVisibility.ts +++ /dev/null @@ -1,102 +0,0 @@ -import * as React from 'react'; -import { Platform, type ViewStyle } from 'react-native'; - -import { - useAnimatedStyle, - useSharedValue, - withSpring, - type AnimatedStyle, - type SharedValue, -} from 'react-native-reanimated'; - -import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; -import { - androidElevationLevels, - shadow, - shadowLayers, -} from '../../theme/tokens/sys/elevation'; -import { toRawSpring } from '../../theme/tokens/sys/motion'; -import type { Elevation, InternalTheme } from '../../types'; - -type UseVisibilityArgs = { - visible: boolean; - theme: InternalTheme; - initialScale?: number; - transformOrigin?: ViewStyle['transformOrigin']; - /** - * Elevation level when shown. Shadow fades in/out with the FAB. - */ - elevation?: Elevation; -}; - -type UseVisibilityResult = { - scale: SharedValue; - alpha: SharedValue; - transformOrigin: ViewStyle['transformOrigin']; - shadowStyle: AnimatedStyle; -}; - -/** - * Animates a FAB in and out: scale + alpha together. - * Reduce-motion: snap to the final value, no animation. - * - * Returns `shadowStyle` too. Put it on the same view as the transform so the - * shadow stays in sync (Android uses `elevation`, iOS uses `shadow*`, Web uses - * `boxShadow` -- the outer container's `opacity: alpha.value` handles the - * visibility fade on Web so the shadow string can be static). - */ -export function useVisibility({ - visible, - theme, - initialScale = 0, - transformOrigin = 'center', - elevation = 0, -}: UseVisibilityArgs): UseVisibilityResult { - const reduceMotion = useReduceMotion(); - const scale = useSharedValue(visible ? 1 : initialScale); - const alpha = useSharedValue(visible ? 1 : 0); - - React.useEffect(() => { - const targetScale = visible ? 1 : initialScale; - const targetAlpha = visible ? 1 : 0; - if (reduceMotion) { - scale.value = targetScale; - alpha.value = targetAlpha; - return; - } - scale.value = withSpring( - targetScale, - toRawSpring(theme.motion.spring.fast.spatial) - ); - alpha.value = withSpring( - targetAlpha, - toRawSpring(theme.motion.spring.fast.effects) - ); - }, [visible, theme, reduceMotion, scale, alpha, initialScale]); - - const restingElevationDp = androidElevationLevels[elevation]; - const shadowOffsetHeight = shadowLayers[0].height[elevation]; - const shadowRadius = shadowLayers[0].shadowRadius[elevation]; - const shadowOpacity = elevation ? shadowLayers[0].shadowOpacity : 0; - const shadowColor = theme.colors.shadow; - - const webShadow = - Platform.OS === 'web' ? shadow(elevation, shadowColor) : null; - - const shadowStyle = useAnimatedStyle(() => { - if (Platform.OS === 'android') { - return { elevation: alpha.value * restingElevationDp }; - } - if (Platform.OS === 'web') { - return webShadow ?? {}; - } - return { - shadowColor, - shadowOpacity: alpha.value * shadowOpacity, - shadowOffset: { width: 0, height: shadowOffsetHeight }, - shadowRadius, - }; - }); - - return { scale, alpha, transformOrigin, shadowStyle }; -} diff --git a/src/components/IconButton/IconButton.tsx b/src/components/IconButton/IconButton.tsx index 270c9289ac..52cb5fa3b2 100644 --- a/src/components/IconButton/IconButton.tsx +++ b/src/components/IconButton/IconButton.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Animated, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import type { ColorValue, GestureResponderEvent, @@ -7,6 +7,8 @@ import type { ViewStyle, } from 'react-native'; +import Animated, { type AnimatedStyle } from 'react-native-reanimated'; + import { getIconButtonColor } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { $RemoveChildren, ThemeProp } from '../../types'; @@ -14,7 +16,6 @@ import ActivityIndicator from '../ActivityIndicator'; import CrossFadeIcon from '../CrossFadeIcon'; import Icon from '../Icon'; import type { IconSource } from '../Icon'; -import Surface from '../Surface'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; const PADDING = 8; @@ -69,7 +70,7 @@ export type Props = Omit<$RemoveChildren, 'style'> & { * Function to execute on press. */ onPress?: (e: GestureResponderEvent) => void; - style?: Animated.WithAnimatedValue>; + style?: StyleProp>; ref?: React.Ref; /** * TestID used for testing purposes @@ -147,34 +148,26 @@ const IconButton = ({ const buttonSize = size + 2 * PADDING; - const { - borderWidth = mode === 'outlined' && !selected ? 1 : 0, - borderRadius = buttonSize / 2, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - } = (StyleSheet.flatten(style) || {}) as ViewStyle; - const borderStyles = { - borderWidth, - borderRadius, + borderWidth: mode === 'outlined' && !selected ? 1 : 0, + borderRadius: buttonSize / 2, borderColor, }; return ( - {backgroundOpacity < 1 && ( - + ); }; const styles = StyleSheet.create({ container: { - overflow: 'hidden', margin: 6, - elevation: 0, + overflow: 'hidden', }, touchable: { flexGrow: 1, diff --git a/src/components/Menu/Menu.tsx b/src/components/Menu/Menu.tsx index ec4c256dad..11d8cc6902 100644 --- a/src/components/Menu/Menu.tsx +++ b/src/components/Menu/Menu.tsx @@ -1,14 +1,12 @@ import * as React from 'react'; import { - Animated, Dimensions, - Easing, Keyboard, Platform, + Pressable, ScrollView, StyleSheet, View, - Pressable, } from 'react-native'; import type { KeyboardEvent as RNKeyboardEvent } from 'react-native'; import type { @@ -20,7 +18,17 @@ import type { ViewStyle, } from 'react-native'; +import Animated, { + Easing, + ReduceMotion, + useAnimatedStyle, + useSharedValue, + withTiming, + type AnimatedStyle, +} from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { scheduleOnRN } from 'react-native-worklets'; +import useLatestCallback from 'use-latest-callback'; import MenuItem from './MenuItem'; import { useLocale } from '../../core/locale'; @@ -67,7 +75,7 @@ export type Props = { /** * Style of menu's inner content. */ - contentStyle?: Animated.WithAnimatedValue>; + contentStyle?: StyleProp>; style?: StyleProp; /** * Elevation level of the menu's content. Shadow styles are calculated based on this value. Default `backgroundColor` is taken from the corresponding `theme.colors.elevation` property. By default equals `2`. @@ -127,8 +135,6 @@ const isCoordinate = (anchor: any): anchor is { x: number; y: number } => typeof anchor?.x === 'number' && typeof anchor?.y === 'number'; -const isBrowser = () => Platform.OS === 'web' && 'document' in global; - /** * Menus display a list of choices on temporary elevated surfaces. Their placement varies based on the element that opens them. * @@ -193,9 +199,12 @@ const Menu = ({ keyboardShouldPersistTaps, }: Props) => { const theme = useInternalTheme(themeOverrides); + const { direction } = useLocale(); const { colors: md3Colors } = theme; + const insets = useSafeAreaInsets(); + const [rendered, setRendered] = React.useState(visible); const [left, setLeft] = React.useState(0); const [top, setTop] = React.useState(0); @@ -209,8 +218,10 @@ const Menu = ({ height: WINDOW_LAYOUT.height, }); - const opacityAnimationRef = React.useRef(new Animated.Value(0)); - const scaleAnimationRef = React.useRef(new Animated.ValueXY({ x: 0, y: 0 })); + const opacity = useSharedValue(0); + const scaleX = useSharedValue(0); + const scaleY = useSharedValue(0); + const keyboardHeightRef = React.useRef(0); const prevVisible = React.useRef(null); const anchorRef = React.useRef(null); @@ -258,7 +269,10 @@ const Menu = ({ const removeListeners = React.useCallback(() => { backHandlerSubscriptionRef.current?.remove(); dimensionsSubscriptionRef.current?.remove(); - isBrowser() && document.removeEventListener('keyup', handleKeypress); + + if (Platform.OS === 'web' && 'document' in global) { + document.removeEventListener('keyup', handleKeypress); + } }, [handleKeypress]); const attachListeners = React.useCallback(() => { @@ -301,6 +315,23 @@ const Menu = ({ [anchor] ); + const handleShowAnimationFinished = useLatestCallback((finished: boolean) => { + if (finished) { + focusFirstDOMNode(menuRef.current); + } + }); + + const handleHideAnimationFinished = useLatestCallback((finished: boolean) => { + if (!finished) { + return; + } + + setMenuLayout({ width: 0, height: 0 }); + setRendered(false); + prevRendered.current = false; + focusFirstDOMNode(anchorRef.current); + }); + const show = React.useCallback(async () => { const windowLayoutResult = Dimensions.get('window'); const [menuLayoutResult, anchorLayoutResult] = await Promise.all([ @@ -343,46 +374,51 @@ const Menu = ({ width: windowLayoutResult.width, }); + prevRendered.current = true; attachListeners(); + requestAnimationFrame(() => { const { animation } = theme; - Animated.parallel([ - Animated.timing(scaleAnimationRef.current, { - toValue: { x: menuLayoutResult.width, y: menuLayoutResult.height }, - duration: ANIMATION_DURATION * animation.scale, - easing: EASING, - useNativeDriver: true, - }), - Animated.timing(opacityAnimationRef.current, { - toValue: 1, - duration: ANIMATION_DURATION * animation.scale, - easing: EASING, - useNativeDriver: true, - }), - ]).start(() => { - focusFirstDOMNode(menuRef.current); - prevRendered.current = true; - }); + + const config = { + duration: ANIMATION_DURATION * animation.scale, + easing: EASING, + reduceMotion: ReduceMotion.Never, + }; + + scaleX.value = withTiming(menuLayoutResult.width, config); + scaleY.value = withTiming(menuLayoutResult.height, config); + + opacity.value = withTiming(1, config, (finished) => + scheduleOnRN(handleShowAnimationFinished, finished ?? false) + ); }); - }, [anchor, attachListeners, measureAnchorLayout, theme]); + }, [ + anchor, + attachListeners, + handleShowAnimationFinished, + measureAnchorLayout, + opacity, + scaleX, + scaleY, + theme, + ]); const hide = React.useCallback(() => { removeListeners(); const { animation } = theme; - Animated.timing(opacityAnimationRef.current, { - toValue: 0, - duration: ANIMATION_DURATION * animation.scale, - easing: EASING, - useNativeDriver: true, - }).start(() => { - setMenuLayout({ width: 0, height: 0 }); - setRendered(false); - prevRendered.current = false; - focusFirstDOMNode(anchorRef.current); - }); - }, [removeListeners, theme]); + opacity.value = withTiming( + 0, + { + duration: ANIMATION_DURATION * animation.scale, + easing: EASING, + reduceMotion: ReduceMotion.Never, + }, + (finished) => scheduleOnRN(handleHideAnimationFinished, finished ?? false) + ); + }, [handleHideAnimationFinished, opacity, removeListeners, theme]); const updateVisibility = React.useCallback( async (display: boolean) => { @@ -403,8 +439,6 @@ const Menu = ({ ); React.useEffect(() => { - const opacityAnimation = opacityAnimationRef.current; - const scaleAnimation = scaleAnimationRef.current; keyboardDidShowListenerRef.current = Keyboard.addListener( 'keyboardDidShow', keyboardDidShow @@ -418,26 +452,24 @@ const Menu = ({ removeListeners(); keyboardDidShowListenerRef.current?.remove(); keyboardDidHideListenerRef.current?.remove(); - scaleAnimation.removeAllListeners(); - opacityAnimation?.removeAllListeners(); }; }, [removeListeners, keyboardDidHide, keyboardDidShow]); + if (visible && !rendered) { + // Mount the Portal before attempting to show. + setRendered(true); + } + React.useEffect(() => { if (prevVisible.current !== visible) { prevVisible.current = visible; - if (visible) { - if (!rendered) { - // Mount the Portal before attempting to show. - setRendered(true); - } - } else { + if (!visible) { // Keep the Portal mounted so the hide animation can finish. void updateVisibility(false); } } - }, [visible, rendered, updateVisibility]); + }, [visible, updateVisibility]); React.useEffect(() => { if (rendered && visible) { @@ -452,7 +484,9 @@ const Menu = ({ }); // We need to translate menu while animating scale to imitate transform origin for scale animation - const positionTransforms = []; + let startTranslateX = 0; + let startTranslateY = 0; + let leftTransformation = left; let topTransformation = !isCoordinate(anchorRef.current) && anchorPosition === 'bottom' @@ -461,24 +495,14 @@ const Menu = ({ // Check if menu fits horizontally and if not align it to right. if (left <= windowLayout.width - menuLayout.width - SCREEN_INDENT) { - positionTransforms.push({ - translateX: scaleAnimationRef.current.x.interpolate({ - inputRange: [0, menuLayout.width], - outputRange: [-(menuLayout.width / 2), 0], - }), - }); + startTranslateX = -(menuLayout.width / 2); // Check if menu position has enough space from left side if (leftTransformation < SCREEN_INDENT) { leftTransformation = SCREEN_INDENT; } } else { - positionTransforms.push({ - translateX: scaleAnimationRef.current.x.interpolate({ - inputRange: [0, menuLayout.width], - outputRange: [menuLayout.width / 2, 0], - }), - }); + startTranslateX = menuLayout.width / 2; leftTransformation += anchorLayout.width - menuLayout.width; @@ -559,24 +583,14 @@ const Menu = ({ // And bottom side of the screen has more space than top side topTransformation <= windowLayout.height - topTransformation) ) { - positionTransforms.push({ - translateY: scaleAnimationRef.current.y.interpolate({ - inputRange: [0, menuLayout.height], - outputRange: [-((scrollableMenuHeight || menuLayout.height) / 2), 0], - }), - }); + startTranslateY = -((scrollableMenuHeight || menuLayout.height) / 2); // Check if menu position has enough space from top side if (topTransformation < SCREEN_INDENT) { topTransformation = SCREEN_INDENT; } } else { - positionTransforms.push({ - translateY: scaleAnimationRef.current.y.interpolate({ - inputRange: [0, menuLayout.height], - outputRange: [(scrollableMenuHeight || menuLayout.height) / 2, 0], - }), - }); + startTranslateY = (scrollableMenuHeight || menuLayout.height) / 2; topTransformation += anchorLayout.height - (scrollableMenuHeight || menuLayout.height); @@ -598,25 +612,39 @@ const Menu = ({ } } - const shadowMenuContainerStyle = { - opacity: opacityAnimationRef.current, + const shadowMenuContainerStyle: ViewStyle = { + borderRadius: theme.shapes.corner.extraSmall, + ...(scrollableMenuHeight ? { height: scrollableMenuHeight } : {}), + }; + + const positionTransformsStyle = useAnimatedStyle(() => { + const scaleXProgress = menuLayout.width + ? scaleX.value / menuLayout.width + : 0; + + const scaleYProgress = menuLayout.height + ? scaleY.value / menuLayout.height + : 0; + + return { + transform: [ + { translateX: startTranslateX * (1 - scaleXProgress) }, + { translateY: startTranslateY * (1 - scaleYProgress) }, + ], + }; + }); + + const shadowMenuAnimationStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, transform: [ { - scaleX: scaleAnimationRef.current.x.interpolate({ - inputRange: [0, menuLayout.width], - outputRange: [0, 1], - }), + scaleX: menuLayout.width ? scaleX.value / menuLayout.width : 0, }, { - scaleY: scaleAnimationRef.current.y.interpolate({ - inputRange: [0, menuLayout.height], - outputRange: [0, 1], - }), + scaleY: menuLayout.height ? scaleY.value / menuLayout.height : 0, }, ], - borderRadius: theme.shapes.corner.extraSmall, - ...(scrollableMenuHeight ? { height: scrollableMenuHeight } : {}), - }; + })); const positionStyle = { top: isCoordinate(anchor) @@ -659,33 +687,38 @@ const Menu = ({ > - {(scrollableMenuHeight && ( - - {children} - - )) || {children}} + + {(scrollableMenuHeight && ( + + {children} + + )) || {children}} + @@ -703,8 +736,13 @@ const styles = StyleSheet.create({ }, shadowMenuContainer: { opacity: 0, + }, + menuContent: { paddingVertical: 8, }, + fill: { + height: '100%', + }, pressableOverlay: { ...Platform.select({ web: { diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 3c66c885b3..8511bda46d 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -1,17 +1,21 @@ import * as React from 'react'; -import { Animated, Easing, StyleSheet, Pressable, View } from 'react-native'; +import { StyleSheet, Pressable, View } from 'react-native'; import type { StyleProp, ViewStyle } from 'react-native'; +import Animated, { + cubicBezier, + type AnimatedStyle, +} from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import useLatestCallback from 'use-latest-callback'; import Surface from './Surface'; +import type { Props as SurfaceProps } from './Surface'; import { useInternalTheme } from '../core/theming'; import { tokens } from '../theme/tokens'; -import type { ThemeProp } from '../types'; +import type { Elevation, ThemeProp } from '../types'; import { addEventListener } from '../utils/addEventListener'; import { BackHandler } from '../utils/BackHandler/BackHandler'; -import useAnimatedValue from '../utils/useAnimatedValue'; const scrimAlpha = tokens.md.sys.scrim.alpha; @@ -41,9 +45,27 @@ export type Props = { */ children: React.ReactNode; /** - * Style for the content of the modal + * Style for the content of the modal. + * + * Background color and border radius should be specified via props instead: + * - `contentBackgroundColor` + * - `contentBorderRadius` */ - contentContainerStyle?: Animated.WithAnimatedValue>; + contentContainerStyle?: StyleProp< + AnimatedStyle> + >; + /** + * Background color of the modal content. Defaults to transparent. + */ + contentBackgroundColor?: SurfaceProps['backgroundColor']; + /** + * Border radius of the modal content. + */ + contentBorderRadius?: SurfaceProps['borderRadius']; + /** + * Elevation level of the modal content. Defaults to level 1. + */ + contentElevation?: Elevation; /** * Style for the wrapper of the modal. * Use this prop to change the default wrapper style or to override safe area insets with marginTop and marginBottom. @@ -77,12 +99,18 @@ const AnimatedPressable = Animated.createAnimatedComponent(Pressable); * * const showModal = () => setVisible(true); * const hideModal = () => setVisible(false); - * const containerStyle = { backgroundColor: 'white', padding: 20 }; + * + * const containerStyle = { padding: 20 }; * * return ( * * - * + * * Example Modal. Click outside this area to dismiss. * * @@ -104,55 +132,46 @@ function Modal({ onDismiss = () => {}, children, contentContainerStyle, + contentBackgroundColor = 'transparent', + contentBorderRadius, + contentElevation, style, theme: themeOverrides, testID = 'modal', }: Props) { const theme = useInternalTheme(themeOverrides); + const onDismissCallback = useLatestCallback(onDismiss); - const { scale } = theme.animation; + const { top, bottom } = useSafeAreaInsets(); - const opacity = useAnimatedValue(visible ? 1 : 0); + const [visibleInternal, setVisibleInternal] = React.useState(visible); + const [animatedVisible, setAnimatedVisible] = React.useState(visible); - const showModalAnimation = React.useCallback(() => { - Animated.timing(opacity, { - toValue: 1, - duration: scale * DEFAULT_DURATION, - easing: Easing.out(Easing.cubic), - useNativeDriver: true, - }).start(); - }, [opacity, scale]); - - const hideModalAnimation = React.useCallback(() => { - Animated.timing(opacity, { - toValue: 0, - duration: scale * DEFAULT_DURATION, - easing: Easing.out(Easing.cubic), - useNativeDriver: true, - }).start(({ finished }) => { - if (!finished) { - return; - } + if (visible && !visibleInternal) { + setVisibleInternal(true); + } - setVisibleInternal(false); - }); - }, [opacity, scale]); + const { scale } = theme.animation; React.useEffect(() => { - if (visibleInternal === visible) { - return; - } + const timeout = setTimeout(() => setAnimatedVisible(visible), 0); - if (!visibleInternal && visible) { - setVisibleInternal(true); - return showModalAnimation(); - } + return () => clearTimeout(timeout); + }, [visible]); - if (visibleInternal && !visible) { - return hideModalAnimation(); + React.useEffect(() => { + if (visible || !visibleInternal) { + return undefined; } - }, [visible, showModalAnimation, hideModalAnimation, visibleInternal]); + + const timeout = setTimeout( + () => setVisibleInternal(false), + scale * DEFAULT_DURATION + ); + + return () => clearTimeout(timeout); + }, [scale, visible, visibleInternal]); React.useEffect(() => { if (!visible) { @@ -172,10 +191,26 @@ function Modal({ 'hardwareBackPress', onHardwareBackPress ); + return () => subscription.remove(); }, [dismissable, dismissableBackButton, onDismissCallback, visible]); - if (!visibleInternal) { + const transitionStyle: AnimatedStyle = { + transitionDuration: scale * DEFAULT_DURATION, + transitionProperty: 'opacity', + transitionTimingFunction: cubicBezier(1 / 3, 1, 2 / 3, 1), + }; + + const backdropStyle: AnimatedStyle = { + backgroundColor: theme.colors.scrim, + opacity: animatedVisible ? scrimAlpha : 0, + }; + + const contentStyle: AnimatedStyle = { + opacity: animatedVisible ? 1 : 0, + }; + + if (!visible && !visibleInternal) { return null; } @@ -194,16 +229,7 @@ function Modal({ disabled={!dismissable} onPress={dismissable ? onDismissCallback : undefined} importantForAccessibility="no" - style={[ - styles.backdrop, - { - backgroundColor: theme.colors.scrim, - opacity: opacity.interpolate({ - inputRange: [0, 1], - outputRange: [0, scrimAlpha], - }), - }, - ]} + style={[styles.backdrop, backdropStyle, transitionStyle]} testID={`${testID}-backdrop`} /> {children} @@ -238,9 +271,7 @@ const styles = StyleSheet.create({ ...StyleSheet.absoluteFill, justifyContent: 'center', }, - // eslint-disable-next-line react-native/no-color-literals content: { - backgroundColor: 'transparent', justifyContent: 'center', }, }); diff --git a/src/components/Searchbar.tsx b/src/components/Searchbar.tsx index b0819b5710..918cbcd398 100644 --- a/src/components/Searchbar.tsx +++ b/src/components/Searchbar.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Animated, Platform, StyleSheet, TextInput, View } from 'react-native'; +import { Platform, StyleSheet, TextInput, View } from 'react-native'; import type { ColorValue, GestureResponderEvent, @@ -9,6 +9,8 @@ import type { ViewStyle, } from 'react-native'; +import type { AnimatedStyle } from 'react-native-reanimated'; + import ActivityIndicator from './ActivityIndicator'; import Divider from './Divider'; import type { IconSource } from './Icon'; @@ -111,12 +113,12 @@ export type Props = TextInputProps & { * @supported Available in v5.x with theme version 3 * Changes Searchbar shadow and background on iOS and Android. */ - elevation?: 0 | 1 | 2 | 3 | 4 | 5 | Animated.Value; + elevation?: 0 | 1 | 2 | 3 | 4 | 5; /** * Set style of the TextInput component inside the searchbar */ inputStyle?: StyleProp; - style?: Animated.WithAnimatedValue>; + style?: StyleProp>; /** * Custom flag for replacing clear button with activity indicator. */ @@ -188,8 +190,10 @@ const Searchbar = ({ ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); + const { direction } = useLocale(); const { colors, fonts } = theme; + const root = React.useRef(null); React.useImperativeHandle(ref, () => ({ @@ -230,18 +234,11 @@ const Searchbar = ({ return ( , 'mode'> & { +export type Props = Omit & { /** * Whether the Snackbar is currently visible. */ @@ -58,7 +68,7 @@ export type Props = $Omit, 'mode'> & { * @supported Available in v5.x with theme version 3 * Changes Snackbar shadow and background on iOS and Android. */ - elevation?: 0 | 1 | 2 | 3 | 4 | 5 | Animated.Value; + elevation?: 0 | 1 | 2 | 3 | 4 | 5; /** * Specifies the largest possible scale a text font can reach. */ @@ -71,7 +81,7 @@ export type Props = $Omit, 'mode'> & { * Style for the content of the snackbar */ contentStyle?: StyleProp; - style?: Animated.WithAnimatedValue>; + style?: StyleProp>; ref?: React.RefObject; /** * @optional @@ -152,82 +162,83 @@ const Snackbar = ({ ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); + const { direction } = useLocale(); + const { bottom, right, left } = useSafeAreaInsets(); - const { current: opacity } = React.useRef( - new Animated.Value(0.0) - ); + const opacity = useSharedValue(0); + const hideTimeout = React.useRef | undefined>( undefined ); + const isMounted = React.useRef(true); const [hidden, setHidden] = React.useState(!visible); const { scale } = theme.animation; - const animateShow = useLatestCallback(() => { - if (hideTimeout.current) clearTimeout(hideTimeout.current); - - Animated.timing(opacity, { - toValue: 1, - duration: 200 * scale, - easing: Easing.out(Easing.ease), - useNativeDriver: true, - }).start(({ finished }) => { - if (finished) { - const isInfinity = - duration === Number.POSITIVE_INFINITY || - duration === Number.NEGATIVE_INFINITY; - - if (!isInfinity) { - hideTimeout.current = setTimeout(onDismiss, duration); - } - } - }); - }); - - const handleOnVisible = useLatestCallback(() => { - // show + if (visible && hidden) { setHidden(false); + } + + const handleShowAnimationFinished = useLatestCallback((finished: boolean) => { + if (!finished || !visible || !isMounted.current) { + return; + } + + const isInfinity = + duration === Number.POSITIVE_INFINITY || + duration === Number.NEGATIVE_INFINITY; + + if (!isInfinity) { + hideTimeout.current = setTimeout(onDismiss, duration); + } }); - const handleOnHidden = useLatestCallback(() => { - // hide + React.useEffect(() => { if (hideTimeout.current) { clearTimeout(hideTimeout.current); + hideTimeout.current = undefined; } - Animated.timing(opacity, { - toValue: 0, - duration: 100 * scale, - useNativeDriver: true, - }).start(({ finished }) => { - if (finished) { - setHidden(true); + opacity.value = withTiming( + visible ? 1 : 0, + { + duration: (visible ? 200 : 100) * scale, + easing: visible ? Easing.out(Easing.ease) : Easing.inOut(Easing.ease), + reduceMotion: ReduceMotion.Never, + }, + (finished) => { + if (visible) { + scheduleOnRN(handleShowAnimationFinished, finished ?? false); + } else if (finished) { + scheduleOnRN(setHidden, true); + } } - }); - }); + ); + }, [handleShowAnimationFinished, opacity, scale, visible]); React.useEffect(() => { - if (!hidden) { - animateShow(); - } - }, [animateShow, hidden]); + isMounted.current = true; - React.useEffect(() => { return () => { - if (hideTimeout.current) clearTimeout(hideTimeout.current); + isMounted.current = false; + + if (hideTimeout.current) { + clearTimeout(hideTimeout.current); + } }; }, []); - React.useLayoutEffect(() => { - if (visible) { - handleOnVisible(); - } else { - handleOnHidden(); - } - }, [visible, handleOnVisible, handleOnHidden]); + const animatedStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + transform: [ + { + scale: visible ? interpolate(opacity.value, [0, 1], [0.9, 1]) : 1, + }, + ], + })); const { colors } = theme; @@ -255,26 +266,21 @@ const Snackbar = ({ paddingHorizontal: Math.max(left, right), }; - const renderChildrenWithWrapper = () => { - if (typeof children === 'string') { - return ( - - {children} - - ); - } - - return ( + const content = + typeof children === 'string' ? ( + + {children} + + ) : ( {/* View is added to allow multiple lines support for Text component as children */} {children} ); - }; return ( - {renderChildrenWithWrapper()} + {content} {(action || isIconButton) && ( {action ? ( @@ -382,10 +375,13 @@ const styles = StyleSheet.create({ width: '100%', }, container: { + margin: 8, + minHeight: 48, + pointerEvents: 'box-none', + }, + contentContainer: { flexDirection: 'row', justifyContent: 'space-between', - margin: 8, - borderRadius: 4, minHeight: 48, }, content: { diff --git a/src/components/Surface.tsx b/src/components/Surface.tsx index b0b0f0c4aa..7f1fce28c9 100644 --- a/src/components/Surface.tsx +++ b/src/components/Surface.tsx @@ -1,205 +1,137 @@ import * as React from 'react'; -import { Animated, Platform, StyleSheet, View } from 'react-native'; -import type { - ColorValue, - ShadowStyleIOS, - StyleProp, - ViewProps, - ViewStyle, -} from 'react-native'; +import { Platform, StyleSheet, View } from 'react-native'; +import type { StyleProp, ViewProps, ViewStyle } from 'react-native'; + +import Animated, { + isSharedValue, + type AnimatedStyle, + useAnimatedStyle, +} from 'react-native-reanimated'; import { useInternalTheme } from '../core/theming'; -import { - androidElevationLevels, - elevationInputRange, - shadow, - shadowLayers, -} from '../theme/tokens/sys/elevation'; +import { androidElevationLevels, shadow } from '../theme/tokens/sys/elevation'; import type { Elevation, ThemeProp } from '../types'; -import { isAnimatedValue } from '../utils/animations'; -import { splitStyles } from '../utils/splitStyles'; -type SurfaceElevation = Elevation | Animated.Value; +type AnimatedStyleProp = Extract< + AnimatedStyle>>, + Record +>[Key]; + +type BorderRadius = AnimatedStyleProp<'borderRadius'>; -export type Props = Omit & { +type SurfaceVisualProps = { + /** + * Background color of the Surface. Overrides the color derived from + * `elevation`. + */ + backgroundColor?: AnimatedStyleProp<'backgroundColor'>; + /** + * Radius of every corner of the Surface. + */ + borderRadius?: BorderRadius; + /** + * Radius of the bottom-end corner of the Surface. + */ + borderBottomEndRadius?: BorderRadius; + /** + * Radius of the bottom-left corner of the Surface. + */ + borderBottomLeftRadius?: BorderRadius; + /** + * Radius of the bottom-right corner of the Surface. + */ + borderBottomRightRadius?: BorderRadius; + /** + * Radius of the bottom-start corner of the Surface. + */ + borderBottomStartRadius?: BorderRadius; + /** + * Radius of the end-end corner of the Surface. + */ + borderEndEndRadius?: BorderRadius; + /** + * Radius of the end-start corner of the Surface. + */ + borderEndStartRadius?: BorderRadius; + /** + * Radius of the start-end corner of the Surface. + */ + borderStartEndRadius?: BorderRadius; /** - * Content of the `Surface`. + * Radius of the start-start corner of the Surface. */ - children: React.ReactNode; - style?: Animated.WithAnimatedValue>; + borderStartStartRadius?: BorderRadius; /** - * @supported Available in v5.x with theme version 3 - * Changes shadows and background on iOS and Android. - * Used to create UI hierarchy between components. - * - * Note: If `mode` is set to `flat`, Surface doesn't have a shadow. - * - * Note: In version 2 the `elevation` prop was accepted via `style` prop i.e. `style={{ elevation: 4 }}`. - * It's no longer supported with theme version 3 and you should use `elevation` property instead. + * Radius of the top-end corner of the Surface. */ - elevation?: SurfaceElevation; + borderTopEndRadius?: BorderRadius; /** - * @supported Available in v5.x with theme version 3 - * Mode of the Surface. - * - `elevated` - Surface with a shadow and background color corresponding to set `elevation` value. - * - `flat` - Surface without a shadow, with the background color corresponding to set `elevation` value. + * Radius of the top-left corner of the Surface. */ - mode?: 'flat' | 'elevated'; + borderTopLeftRadius?: BorderRadius; /** - * @optional + * Radius of the top-right corner of the Surface. */ - theme?: ThemeProp; + borderTopRightRadius?: BorderRadius; /** - * TestID used for testing purposes + * Radius of the top-start corner of the Surface. */ - testID?: string; - ref?: React.Ref; + borderTopStartRadius?: BorderRadius; /** - * @internal + * Corner curve of the Surface on iOS. */ - container?: boolean; + borderCurve?: ViewStyle['borderCurve']; }; -const outerLayerStyleProperties: (keyof ViewStyle)[] = [ - 'position', - 'alignSelf', - 'top', - 'right', - 'bottom', - 'left', - 'start', - 'end', - 'flex', - 'flexShrink', - 'flexGrow', - 'width', - 'height', - 'transform', - 'opacity', -]; - -function getStyleForShadowLayer( - elevation: SurfaceElevation, - layer: 0 | 1, - shadowColor: ColorValue -): Animated.WithAnimatedValue { - if (isAnimatedValue(elevation)) { - return { - shadowColor, - shadowOpacity: elevation.interpolate({ - inputRange: [0, 1], - outputRange: [0, shadowLayers[layer].shadowOpacity], - extrapolate: 'clamp', - }), - shadowOffset: { - width: 0, - height: elevation.interpolate({ - inputRange: elevationInputRange, - outputRange: shadowLayers[layer].height, - }), - }, - shadowRadius: elevation.interpolate({ - inputRange: elevationInputRange, - outputRange: shadowLayers[layer].shadowRadius, - }), - }; - } - - return { - shadowColor, - shadowOpacity: elevation ? shadowLayers[layer].shadowOpacity : 0, - shadowOffset: { - width: 0, - height: shadowLayers[layer].height[elevation], - }, - shadowRadius: shadowLayers[layer].shadowRadius[elevation], +export type Props = Omit & + SurfaceVisualProps & { + /** + * Style of the Surface. + * + * This doesn't support all View style properties: + * - Background color and border radius should be specified via props instead. + * - `overflow: 'hidden'` is not supported with `elevation` as it can clip the shadow. + * To achieve the same effect, wrap the content in a child View with the overflow style. + */ + style?: StyleProp>>; + /** + * @supported Available in v5.x with theme version 3 + * Changes shadows and background on iOS and Android. + * Used to create UI hierarchy between components. + * + * Note: If `mode` is set to `flat`, Surface doesn't have a shadow. + * + * Note: In version 2 the `elevation` prop was accepted via `style` prop i.e. `style={{ elevation: 4 }}`. + * It's no longer supported with theme version 3 and you should use `elevation` property instead. + */ + elevation?: Elevation; + /** + * @supported Available in v5.x with theme version 3 + * Mode of the Surface. + * - `elevated` - Surface with a shadow and background color corresponding to set `elevation` value. + * - `flat` - Surface without a shadow, with the background color corresponding to set `elevation` value. + */ + mode?: 'flat' | 'elevated'; + /** + * @optional + */ + theme?: ThemeProp; + /** + * Content of the `Surface`. + */ + children: React.ReactNode; + /** + * TestID used for testing purposes + */ + testID?: string; + ref?: React.Ref; }; -} - -type SurfaceIOSProps = Omit & { - elevation: SurfaceElevation; - backgroundColor?: - | ColorValue - | Animated.AnimatedInterpolation; - shadowColor: ColorValue; -}; - -const SurfaceIOS = ({ - elevation, - style, - backgroundColor, - shadowColor, - testID, - children, - mode = 'elevated', - container, - ref, - ...props -}: SurfaceIOSProps) => { - const [outerLayerViewStyles, innerLayerViewStyles] = React.useMemo(() => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const flattenedStyles = (StyleSheet.flatten(style) || {}) as ViewStyle; - - const [filteredStyles, outerLayerStyles, borderRadiusStyles] = splitStyles( - flattenedStyles, - (style) => - outerLayerStyleProperties.includes(style) || style.startsWith('margin'), - (style) => style.startsWith('border') && style.endsWith('Radius') - ); - - if ( - process.env.NODE_ENV !== 'production' && - filteredStyles.overflow === 'hidden' && - elevation !== 0 - ) { - console.warn( - 'When setting overflow to hidden on Surface the shadow will not be displayed correctly. Wrap the content of your component in a separate View with the overflow style.' - ); - } - - const bgColor = flattenedStyles.backgroundColor || backgroundColor; - - const isElevated = mode === 'elevated'; - - const outerLayerViewStyles = { - ...(isElevated && getStyleForShadowLayer(elevation, 0, shadowColor)), - ...outerLayerStyles, - ...borderRadiusStyles, - backgroundColor: bgColor, - }; - - const innerLayerViewStyles = { - ...(isElevated && getStyleForShadowLayer(elevation, 1, shadowColor)), - ...filteredStyles, - ...borderRadiusStyles, - flex: - flattenedStyles.height || (!container && flattenedStyles.flex) - ? 1 - : undefined, - backgroundColor: bgColor, - }; - - return [outerLayerViewStyles, innerLayerViewStyles]; - }, [style, elevation, backgroundColor, shadowColor, mode, container]); - - return ( - - - {children} - - - ); -}; /** * Surface is a basic container that can give depth to an element with elevation shadow. - * On dark theme with `adaptive` mode, surface is constructed by also placing a semi-transparent white overlay over a component surface. - * See [Dark Theme](https://callstack.github.io/react-native-paper/docs/guides/theming#dark-theme) for more information. - * Overlay and shadow can be applied by specifying the `elevation` property both on Android and iOS. + * + * On Android, Surface uses the native `elevation` style, + * and falls back to shadows that approximate the elevation on other platforms. * * ## Usage * ```js @@ -208,7 +140,7 @@ const SurfaceIOS = ({ * import { StyleSheet } from 'react-native'; * * const MyComponent = () => ( - * + * * Surface * * ); @@ -217,9 +149,9 @@ const SurfaceIOS = ({ * * const styles = StyleSheet.create({ * surface: { - * padding: 8, * height: 80, * width: 80, + * padding: 8, * alignItems: 'center', * justifyContent: 'center', * }, @@ -231,44 +163,86 @@ const Surface = ({ children, theme: overridenTheme, style, - testID = 'surface', + backgroundColor: customBackgroundColor, + borderRadius, + borderBottomEndRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderBottomStartRadius, + borderEndEndRadius, + borderEndStartRadius, + borderStartEndRadius, + borderStartStartRadius, + borderTopEndRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderTopStartRadius, + borderCurve, + testID, mode = 'elevated', ref, - ...props + ...rest }: Props) => { const theme = useInternalTheme(overridenTheme); const { colors } = theme; - const backgroundColor = (() => { - if (isAnimatedValue(elevation)) { - return elevation.interpolate({ - inputRange: elevationInputRange, - outputRange: elevationInputRange.map((elevation) => { - return colors.elevation?.[`level${elevation}`]; - }), - }); - } + const backgroundColor = + customBackgroundColor ?? colors.elevation?.[`level${elevation}`]; - return colors.elevation?.[`level${elevation}`]; - })(); + const visualStyle = useAnimatedStyle(() => + Object.fromEntries( + Object.entries({ + backgroundColor, + borderRadius, + borderBottomEndRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderBottomStartRadius, + borderEndEndRadius, + borderEndStartRadius, + borderStartEndRadius, + borderStartStartRadius, + borderTopEndRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderTopStartRadius, + borderCurve, + }).map(([property, value]) => [ + property, + isSharedValue(value) ? value.value : value, + ]) + ) + ); const isElevated = mode === 'elevated'; + const transitionDuration = 150 * theme.animation.scale; + + let transitionStyle: AnimatedStyle = { + transitionDuration, + transitionTimingFunction: 'ease-in-out', + }; + if (Platform.OS === 'web') { - const { pointerEvents = 'auto' } = props; + const [elevationShadow] = shadow(elevation, theme.colors.shadow); + + transitionStyle = { + ...transitionStyle, + transitionProperty: 'boxShadow', + }; + return ( {children} @@ -277,40 +251,23 @@ const Surface = ({ } if (Platform.OS === 'android') { - const getElevationAndroid = () => { - if (isAnimatedValue(elevation)) { - return elevation.interpolate({ - inputRange: elevationInputRange, - outputRange: androidElevationLevels, - }); - } + const elevationAndroid = androidElevationLevels[elevation]; - return androidElevationLevels[elevation]; + transitionStyle = { + ...transitionStyle, + transitionProperty: 'elevation', }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const { margin, padding, transform, borderRadius } = (StyleSheet.flatten( - style - ) || {}) as ViewStyle; - - const outerLayerStyles = { margin, padding, transform, borderRadius }; - const sharedStyle = [{ backgroundColor }, style]; - return ( {children} @@ -318,20 +275,42 @@ const Surface = ({ ); } + const [spotShadow, ambientShadow] = shadow(elevation, theme.colors.shadow); + + transitionStyle = { + ...transitionStyle, + transitionProperty: ['shadowOpacity', 'shadowOffset', 'shadowRadius'], + }; + return ( - + {children} - + ); }; +const styles = StyleSheet.create({ + container: { + pointerEvents: 'auto', + }, + shadow: { + pointerEvents: 'none', + }, +}); + export default Surface; diff --git a/src/components/ToggleButton/ToggleButton.tsx b/src/components/ToggleButton/ToggleButton.tsx index 22598c16b2..50bb50547e 100644 --- a/src/components/ToggleButton/ToggleButton.tsx +++ b/src/components/ToggleButton/ToggleButton.tsx @@ -1,7 +1,9 @@ import * as React from 'react'; -import { StyleSheet, View, Animated } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import type { GestureResponderEvent, StyleProp, ViewStyle } from 'react-native'; +import type { AnimatedStyle } from 'react-native-reanimated'; + import { ToggleButtonGroupContext } from './ToggleButtonGroup'; import { getToggleButtonColor } from './utils'; import { useInternalTheme } from '../../core/theming'; @@ -42,7 +44,7 @@ export type Props = { * Status of button. */ status?: 'checked' | 'unchecked'; - style?: Animated.WithAnimatedValue>; + style?: StyleProp>; /** * @optional */ diff --git a/src/components/__tests__/Appbar/Appbar.test.tsx b/src/components/__tests__/Appbar/Appbar.test.tsx index 27cd573f9d..8ad6b28645 100644 --- a/src/components/__tests__/Appbar/Appbar.test.tsx +++ b/src/components/__tests__/Appbar/Appbar.test.tsx @@ -1,12 +1,8 @@ -import { Animated } from 'react-native'; - -import { describe, expect, it, jest } from '@jest/globals'; -import { act } from '@testing-library/react-native'; +import { describe, expect, it } from '@jest/globals'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { getTheme } from '../../../core/theming'; import { render, screen } from '../../../test-utils'; -import { tokens } from '../../../theme/tokens'; import Appbar from '../../Appbar'; import { getAppbarBackgroundColor, @@ -280,150 +276,83 @@ describe('AppbarContent', () => { }); describe('getAppbarColors', () => { - const elevation = 4; + const elevated = true; const customBackground = 'aquamarine'; it('should return custom color no matter what is the theme version', () => { expect( - getAppbarBackgroundColor(getTheme(), elevation, customBackground) + getAppbarBackgroundColor(getTheme(), elevated, customBackground) ).toBe(customBackground); }); - it('should return v3 light color if theme version is 3', () => { - expect(getAppbarBackgroundColor(getTheme(), elevation)).toBe( - tokens.md.ref.palette.neutral98 + it('returns the light surface container color for an elevated appbar', () => { + expect(getAppbarBackgroundColor(getTheme(), elevated)).toBe( + getTheme().colors.surfaceContainer ); }); - it('should return v3 dark color if theme version is 3', () => { - expect(getAppbarBackgroundColor(getTheme(true), elevation)).toBe( - tokens.md.ref.palette.neutral6 + it('returns the dark surface container color for an elevated appbar', () => { + expect(getAppbarBackgroundColor(getTheme(true), elevated)).toBe( + getTheme(true).colors.surfaceContainer ); }); }); -describe('animated value changes correctly', () => { - it('appbar animated value changes correctly', async () => { - const value = new Animated.Value(1); +describe('style props', () => { + it('appbar style is applied correctly', async () => { await render( - + ); - expect(screen.getByTestId('appbar-outer-layer')).toHaveStyle({ - transform: [{ scale: 1 }], - }); - - Animated.timing(value, { - toValue: 1.5, - useNativeDriver: false, - duration: 200, - }).start(); - await act(() => { - jest.advanceTimersByTime(200); - }); - - expect(screen.getByTestId('appbar-outer-layer')).toHaveStyle({ - transform: [{ scale: 1.5 }], + expect(screen.getByTestId('appbar')).toHaveStyle({ + marginTop: 12, }); }); - it('action animated value changes correctly', async () => { - const value = new Animated.Value(1); + it('action style is applied correctly', async () => { await render( ); - expect( - screen.getByTestId('appbar-action-container-outer-layer') - ).toHaveStyle({ - transform: [{ scale: 1 }], - }); - - Animated.timing(value, { - toValue: 1.5, - useNativeDriver: false, - duration: 200, - }).start(); - - await act(() => { - jest.advanceTimersByTime(200); - }); - expect( - screen.getByTestId('appbar-action-container-outer-layer') - ).toHaveStyle({ - transform: [{ scale: 1.5 }], + expect(screen.getByTestId('appbar-action-container')).toHaveStyle({ + marginTop: 12, }); }); - it('back action animated value changes correctly', async () => { - const value = new Animated.Value(1); + it('back action style is applied correctly', async () => { await render( ); - expect( - screen.getByTestId('appbar-back-action-container-outer-layer') - ).toHaveStyle({ - transform: [{ scale: 1 }], - }); - - Animated.timing(value, { - toValue: 1.5, - useNativeDriver: false, - duration: 200, - }).start(); - - await act(() => { - jest.advanceTimersByTime(200); - }); - expect( - screen.getByTestId('appbar-back-action-container-outer-layer') - ).toHaveStyle({ - transform: [{ scale: 1.5 }], + expect(screen.getByTestId('appbar-back-action-container')).toHaveStyle({ + marginTop: 12, }); }); - it('header animated value changes correctly', async () => { - const value = new Animated.Value(1); + it('header style is applied correctly', async () => { await render( - + {null} ); - expect(screen.getByTestId('appbar-header-outer-layer')).toHaveStyle({ - transform: [{ scale: 1 }], - }); - - Animated.timing(value, { - toValue: 1.5, - useNativeDriver: false, - duration: 200, - }).start(); - await act(() => { - jest.advanceTimersByTime(200); - }); - - expect(screen.getByTestId('appbar-header-outer-layer')).toHaveStyle({ - transform: [{ scale: 1.5 }], + expect(screen.getByTestId('appbar-header')).toHaveStyle({ + marginTop: 12, }); }); @@ -437,7 +366,8 @@ describe('animated value changes correctly', () => { ); - expect(screen.getByTestId('appbar-header-root-layer')).toHaveStyle(style); + + expect(screen.getByTestId('appbar-header')).toHaveStyle(style); }); describe('getAppbarBorders', () => { diff --git a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap index a5d9d95766..db67c1b3b9 100644 --- a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap +++ b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap @@ -3,34 +3,105 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` - + - + + - + + - - - - - magnify - - - - + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + magnify + - + + + + + - - - - - - close - - - - + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + close + @@ -419,34 +769,105 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and AppbarAction 1`] = ` - + - + + - - + + - - - - - + } + /> - + + - - Examples - - - + Examples + + + + - - - - menu - - - + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + menu + diff --git a/src/components/__tests__/Banner.test.tsx b/src/components/__tests__/Banner.test.tsx index 80bd3e9017..5cbd07b387 100644 --- a/src/components/__tests__/Banner.test.tsx +++ b/src/components/__tests__/Banner.test.tsx @@ -1,4 +1,4 @@ -import { Animated, Image } from 'react-native'; +import { Image } from 'react-native'; import { afterAll, @@ -11,7 +11,7 @@ import { } from '@jest/globals'; import { act } from '@testing-library/react-native'; -import { render, screen } from '../../test-utils'; +import { render } from '../../test-utils'; import Banner from '../Banner'; it('renders hidden banner, without action buttons and without image', async () => { @@ -356,34 +356,4 @@ describe('animations', () => { expect(nextHideCallback).toHaveBeenCalledTimes(1); }); }); - - it('animated value changes correctly', async () => { - const value = new Animated.Value(1); - await render( - - Banner - - ); - expect(screen.getByTestId('banner-outer-layer')).toHaveStyle({ - transform: [{ scale: 1 }], - }); - - Animated.timing(value, { - toValue: 1.5, - useNativeDriver: false, - duration: 200, - }).start(); - - await act(() => { - jest.runAllTimers(); - }); - - expect(screen.getByTestId('banner-outer-layer')).toHaveStyle({ - transform: [{ scale: 1.5 }], - }); - }); }); diff --git a/src/components/__tests__/BottomNavigation.test.tsx b/src/components/__tests__/BottomNavigation.test.tsx index 21e495742a..163fd6612a 100644 --- a/src/components/__tests__/BottomNavigation.test.tsx +++ b/src/components/__tests__/BottomNavigation.test.tsx @@ -601,7 +601,10 @@ it('barStyle animated value changes correctly', async () => { barStyle={[{ transform: [{ scale: value }] }]} /> ); - expect(screen.getByTestId('bottom-navigation-bar-outer-layer')).toHaveStyle({ + + expect( + screen.getByTestId('bottom-navigation-bar-animated-wrapper') + ).toHaveStyle({ transform: [{ scale: 1 }], }); @@ -614,7 +617,10 @@ it('barStyle animated value changes correctly', async () => { await act(() => { jest.advanceTimersByTime(200); }); - expect(screen.getByTestId('bottom-navigation-bar-outer-layer')).toHaveStyle({ + + expect( + screen.getByTestId('bottom-navigation-bar-animated-wrapper') + ).toHaveStyle({ transform: [{ scale: 1.5 }], }); }); diff --git a/src/components/__tests__/Button.test.tsx b/src/components/__tests__/Button.test.tsx index 4da466837a..7612184e48 100644 --- a/src/components/__tests__/Button.test.tsx +++ b/src/components/__tests__/Button.test.tsx @@ -1,7 +1,7 @@ -import { Animated, StyleSheet } from 'react-native'; +import { StyleSheet } from 'react-native'; import { describe, expect, it, jest } from '@jest/globals'; -import { act, userEvent } from '@testing-library/react-native'; +import { userEvent } from '@testing-library/react-native'; import { getTheme } from '../../core/theming'; import { render, screen } from '../../test-utils'; @@ -709,33 +709,3 @@ describe('getButtonColors - border width', () => { }) ); }); - -it('animated value changes correctly', async () => { - const value = new Animated.Value(1); - await render( - - ); - expect(screen.getByTestId('button-container-outer-layer')).toHaveStyle({ - transform: [{ scale: 1 }], - }); - - Animated.timing(value, { - toValue: 1.5, - useNativeDriver: false, - duration: 200, - }).start(); - - await act(() => { - jest.advanceTimersByTime(200); - }); - expect(screen.getByTestId('button-container-outer-layer')).toHaveStyle({ - transform: [{ scale: 1.5 }], - }); -}); diff --git a/src/components/__tests__/Card/Card.test.tsx b/src/components/__tests__/Card/Card.test.tsx index d9e91d9d73..66450300ae 100644 --- a/src/components/__tests__/Card/Card.test.tsx +++ b/src/components/__tests__/Card/Card.test.tsx @@ -1,7 +1,6 @@ -import { Animated, StyleSheet, Text } from 'react-native'; +import { StyleSheet, Text } from 'react-native'; -import { describe, expect, it, jest } from '@jest/globals'; -import { act } from '@testing-library/react-native'; +import { describe, expect, it } from '@jest/globals'; import { getTheme } from '../../../core/theming'; import { render, screen } from '../../../test-utils'; @@ -224,32 +223,3 @@ describe('getCardCoverStyle - border radius', () => { ).toMatchObject({ borderRadius: getTheme().shapes.corner.medium }); }); }); - -it('animated value changes correctly', async () => { - const value = new Animated.Value(1); - await render( - - {null} - - ); - expect(screen.getByTestId('card-container-outer-layer')).toHaveStyle({ - transform: [{ scale: 1 }], - }); - - Animated.timing(value, { - toValue: 1.5, - useNativeDriver: false, - duration: 200, - }).start(); - - await act(() => { - jest.advanceTimersByTime(200); - }); - expect(screen.getByTestId('card-container-outer-layer')).toHaveStyle({ - transform: [{ scale: 1.5 }], - }); -}); diff --git a/src/components/__tests__/Card/__snapshots__/Card.test.tsx.snap b/src/components/__tests__/Card/__snapshots__/Card.test.tsx.snap index d1492cc475..e4df7ba341 100644 --- a/src/components/__tests__/Card/__snapshots__/Card.test.tsx.snap +++ b/src/components/__tests__/Card/__snapshots__/Card.test.tsx.snap @@ -3,28 +3,85 @@ exports[`Card renders an outlined card 1`] = ` - + - - - + "shadowOpacity": 0, + "shadowRadius": 0, + }, + ] + } + style={ + [ + { + "bottom": 0, + "left": 0, + "position": "absolute", + "right": 0, + "top": 0, + }, + { + "pointerEvents": "none", + }, + {}, + { + "backgroundColor": "rgba(254, 247, 255, 1)", + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderCurve": undefined, + "borderEndEndRadius": undefined, + "borderEndStartRadius": undefined, + "borderRadius": 12, + "borderStartEndRadius": undefined, + "borderStartStartRadius": undefined, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "shadowColor": "rgba(0, 0, 0, 1)", + "shadowOffset": { + "height": 0, + "width": 0, + }, + "shadowOpacity": 0, + "shadowRadius": 0, + }, + ] + } + testID="card-container-shadow-layer" + /> + + `; diff --git a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap index 54f2e4f7a4..8e23dac3e3 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap @@ -83,6 +83,35 @@ exports[`renders Checkbox with custom testID 1`] = ` } > { }); }); }); - -it('animated value changes correctly', async () => { - const value = new Animated.Value(1); - await render( - {}} - testID="chip" - style={[{ transform: [{ scale: value }] }]} - > - Example Chip - - ); - expect(screen.getByTestId('chip-container-outer-layer')).toHaveStyle({ - transform: [{ scale: 1 }], - }); - - Animated.timing(value, { - toValue: 1.5, - useNativeDriver: false, - duration: 200, - }).start(); - - await act(() => { - jest.advanceTimersByTime(200); - }); - expect(screen.getByTestId('chip-container-outer-layer')).toHaveStyle({ - transform: [{ scale: 1.5 }], - }); -}); diff --git a/src/components/__tests__/IconButton.test.tsx b/src/components/__tests__/IconButton.test.tsx index b28456c5ce..a8bc540aa9 100644 --- a/src/components/__tests__/IconButton.test.tsx +++ b/src/components/__tests__/IconButton.test.tsx @@ -1,7 +1,6 @@ -import { Animated, StyleSheet } from 'react-native'; +import { StyleSheet } from 'react-native'; -import { describe, expect, it, jest } from '@jest/globals'; -import { act } from '@testing-library/react-native'; +import { describe, expect, it } from '@jest/globals'; import { getTheme } from '../../core/theming'; import { render, screen } from '../../test-utils'; @@ -318,30 +317,3 @@ describe('getIconButtonColor - border color', () => { }); }); }); - -it('action animated value changes correctly', async () => { - const value = new Animated.Value(1); - await render( - - ); - expect(screen.getByTestId('icon-button-container-outer-layer')).toHaveStyle({ - transform: [{ scale: 1 }], - }); - - Animated.timing(value, { - toValue: 1.5, - useNativeDriver: false, - duration: 200, - }).start(); - - await act(() => { - jest.advanceTimersByTime(200); - }); - expect(screen.getByTestId('icon-button-container-outer-layer')).toHaveStyle({ - transform: [{ scale: 1.5 }], - }); -}); diff --git a/src/components/__tests__/Menu.test.tsx b/src/components/__tests__/Menu.test.tsx index bd14689f70..018e0c9b71 100644 --- a/src/components/__tests__/Menu.test.tsx +++ b/src/components/__tests__/Menu.test.tsx @@ -1,4 +1,4 @@ -import { Animated, Dimensions, StyleSheet, View } from 'react-native'; +import { Dimensions, StyleSheet, View } from 'react-native'; import { expect, it, jest } from '@jest/globals'; import { act, screen, waitFor } from '@testing-library/react-native'; @@ -210,8 +210,7 @@ it('respects anchorPosition bottom', async () => { dimensionsSpy.mockRestore(); }); -it('animated value changes correctly', async () => { - const value = new Animated.Value(1); +it('applies content styles', async () => { await render( { onDismiss={jest.fn()} anchor={} testID="menu" - contentStyle={[{ transform: [{ scale: value }] }]} + contentStyle={{ marginTop: 12 }} > ); - expect(screen.getByTestId('menu-surface-outer-layer')).toHaveStyle({ - transform: [{ scale: 1 }], - }); - - Animated.timing(value, { - toValue: 1.5, - useNativeDriver: false, - duration: 200, - }).start(); - await act(() => { - jest.advanceTimersByTime(200); - }); - expect(screen.getByTestId('menu-surface-outer-layer')).toHaveStyle({ - transform: [{ scale: 1.5 }], + expect(screen.getByTestId('menu-surface-content')).toHaveStyle({ + marginTop: 12, }); }); diff --git a/src/components/__tests__/Modal.test.tsx b/src/components/__tests__/Modal.test.tsx index ff4cf2fd41..887f300458 100644 --- a/src/components/__tests__/Modal.test.tsx +++ b/src/components/__tests__/Modal.test.tsx @@ -1,4 +1,4 @@ -import { Animated, BackHandler as RNBackHandler, Text } from 'react-native'; +import { BackHandler as RNBackHandler, Text } from 'react-native'; import type { BackHandlerStatic as RNBackHandlerStatic } from 'react-native'; import { afterAll, beforeAll, describe, expect, it, jest } from '@jest/globals'; @@ -111,7 +111,7 @@ describe('Modal', () => { expect(onDismiss).toHaveBeenCalled(); - expect(screen.getByTestId('modal-surface-outer-layer')).toHaveStyle({ + expect(screen.getByTestId('modal-surface')).toHaveStyle({ opacity: 1, }); @@ -123,7 +123,7 @@ describe('Modal', () => { opacity: scrimAlpha, }); - expect(screen.getByTestId('modal-surface-outer-layer')).toHaveStyle({ + expect(screen.getByTestId('modal-surface')).toHaveStyle({ opacity: 1, }); @@ -138,7 +138,7 @@ describe('Modal', () => { ); - expect(screen.getByTestId('modal-surface-outer-layer')).toHaveStyle({ + expect(screen.getByTestId('modal-surface')).toHaveStyle({ opacity: 1, }); @@ -150,7 +150,7 @@ describe('Modal', () => { ); - expect(screen.getByTestId('modal-surface-outer-layer')).toHaveStyle({ + expect(screen.getByTestId('modal-surface')).toHaveStyle({ opacity: 1, }); @@ -158,7 +158,7 @@ describe('Modal', () => { opacity: scrimAlpha, }); - expect(screen.getByTestId('modal-surface-outer-layer')).toHaveStyle({ + expect(screen.getByTestId('modal-surface')).toHaveStyle({ opacity: 1, }); @@ -166,9 +166,7 @@ describe('Modal', () => { jest.runAllTimers(); }); - expect( - screen.queryByTestId('modal-surface-outer-layer') - ).not.toBeOnTheScreen(); + expect(screen.queryByTestId('modal-surface')).not.toBeOnTheScreen(); expect(screen.queryByTestId('modal-backdrop')).not.toBeOnTheScreen(); }); @@ -182,7 +180,7 @@ describe('Modal', () => { ); - expect(screen.getByTestId('modal-surface-outer-layer')).toHaveStyle({ + expect(screen.getByTestId('modal-surface')).toHaveStyle({ opacity: 1, }); @@ -190,7 +188,7 @@ describe('Modal', () => { BackHandler.mockPressBack(); }); - expect(screen.getByTestId('modal-surface-outer-layer')).toHaveStyle({ + expect(screen.getByTestId('modal-surface')).toHaveStyle({ opacity: 1, }); @@ -202,7 +200,7 @@ describe('Modal', () => { opacity: scrimAlpha, }); - expect(screen.getByTestId('modal-surface-outer-layer')).toHaveStyle({ + expect(screen.getByTestId('modal-surface')).toHaveStyle({ opacity: 1, }); @@ -225,13 +223,13 @@ describe('Modal', () => { ); - expect(screen.getByTestId('modal-surface-outer-layer')).toHaveStyle({ + expect(screen.getByTestId('modal-surface')).toHaveStyle({ opacity: 1, }); await userEvent.press(screen.getByTestId('modal-backdrop')); - expect(screen.getByTestId('modal-surface-outer-layer')).toHaveStyle({ + expect(screen.getByTestId('modal-surface')).toHaveStyle({ opacity: 1, }); @@ -243,7 +241,7 @@ describe('Modal', () => { opacity: scrimAlpha, }); - expect(screen.getByTestId('modal-surface-outer-layer')).toHaveStyle({ + expect(screen.getByTestId('modal-surface')).toHaveStyle({ opacity: 1, }); }); @@ -288,7 +286,7 @@ describe('Modal', () => { ); - expect(screen.getByTestId('modal-surface-outer-layer')).toHaveStyle({ + expect(screen.getByTestId('modal-surface')).toHaveStyle({ opacity: 1, }); @@ -296,7 +294,7 @@ describe('Modal', () => { BackHandler.mockPressBack(); }); - expect(screen.getByTestId('modal-surface-outer-layer')).toHaveStyle({ + expect(screen.getByTestId('modal-surface')).toHaveStyle({ opacity: 1, }); @@ -308,7 +306,7 @@ describe('Modal', () => { opacity: scrimAlpha, }); - expect(screen.getByTestId('modal-surface-outer-layer')).toHaveStyle({ + expect(screen.getByTestId('modal-surface')).toHaveStyle({ opacity: 1, }); }); @@ -364,7 +362,7 @@ describe('Modal', () => { expect(screen.getByTestId('modal-backdrop')).toHaveStyle({ opacity: 0, }); - expect(screen.getByTestId('modal-surface-outer-layer')).toHaveStyle({ + expect(screen.getByTestId('modal-surface')).toHaveStyle({ opacity: 0, }); @@ -375,7 +373,7 @@ describe('Modal', () => { expect(screen.getByTestId('modal-backdrop')).toHaveStyle({ opacity: scrimAlpha, }); - expect(screen.getByTestId('modal-surface-outer-layer')).toHaveStyle({ + expect(screen.getByTestId('modal-surface')).toHaveStyle({ opacity: 1, }); }); @@ -392,7 +390,7 @@ describe('Modal', () => { expect(screen.getByTestId('modal-backdrop')).toHaveStyle({ opacity: scrimAlpha, }); - expect(screen.getByTestId('modal-surface-outer-layer')).toHaveStyle({ + expect(screen.getByTestId('modal-surface')).toHaveStyle({ opacity: 1, }); @@ -405,7 +403,7 @@ describe('Modal', () => { expect(screen.getByTestId('modal-backdrop')).toHaveStyle({ opacity: scrimAlpha, }); - expect(screen.getByTestId('modal-surface-outer-layer')).toHaveStyle({ + expect(screen.getByTestId('modal-surface')).toHaveStyle({ opacity: 1, }); @@ -452,7 +450,7 @@ describe('Modal', () => { expect(screen.getByTestId('modal-backdrop')).toHaveStyle({ opacity: scrimAlpha, }); - expect(screen.getByTestId('modal-surface-outer-layer')).toHaveStyle({ + expect(screen.getByTestId('modal-surface')).toHaveStyle({ opacity: 1, }); @@ -465,7 +463,7 @@ describe('Modal', () => { expect(screen.getByTestId('modal-backdrop')).toHaveStyle({ opacity: scrimAlpha, }); - expect(screen.getByTestId('modal-surface-outer-layer')).toHaveStyle({ + expect(screen.getByTestId('modal-surface')).toHaveStyle({ opacity: 1, }); @@ -490,7 +488,7 @@ describe('Modal', () => { expect(screen.getByTestId('modal-backdrop')).toHaveStyle({ opacity: scrimAlpha, }); - expect(screen.getByTestId('modal-surface-outer-layer')).toHaveStyle({ + expect(screen.getByTestId('modal-surface')).toHaveStyle({ opacity: 1, }); @@ -503,7 +501,7 @@ describe('Modal', () => { expect(screen.getByTestId('modal-backdrop')).toHaveStyle({ opacity: scrimAlpha, }); - expect(screen.getByTestId('modal-surface-outer-layer')).toHaveStyle({ + expect(screen.getByTestId('modal-surface')).toHaveStyle({ opacity: 1, }); @@ -526,7 +524,7 @@ describe('Modal', () => { expect(screen.getByTestId('modal-backdrop')).toHaveStyle({ opacity: scrimAlpha, }); - expect(screen.getByTestId('modal-surface-outer-layer')).toHaveStyle({ + expect(screen.getByTestId('modal-surface')).toHaveStyle({ opacity: 1, }); }); @@ -551,7 +549,7 @@ describe('Modal', () => { expect(screen.getByTestId('modal-backdrop')).toHaveStyle({ opacity: 0, }); - expect(screen.getByTestId('modal-surface-outer-layer')).toHaveStyle({ + expect(screen.getByTestId('modal-surface')).toHaveStyle({ opacity: 0, }); @@ -578,33 +576,19 @@ describe('Modal', () => { }); }); - it('animated value changes correctly', async () => { - const value = new Animated.Value(1); + it('applies content container style', async () => { await render( {null} ); - expect(screen.getByTestId('modal-surface-outer-layer')).toHaveStyle({ - transform: [{ scale: 1 }], - }); - - Animated.timing(value, { - toValue: 1.5, - useNativeDriver: false, - duration: 200, - }).start(); - - await act(() => { - jest.runAllTimers(); - }); - expect(screen.getByTestId('modal-surface-outer-layer')).toHaveStyle({ - transform: [{ scale: 1.5 }], + expect(screen.getByTestId('modal-surface')).toHaveStyle({ + marginTop: 12, }); }); }); diff --git a/src/components/__tests__/Searchbar.test.tsx b/src/components/__tests__/Searchbar.test.tsx index 0671263277..5fea2800ed 100644 --- a/src/components/__tests__/Searchbar.test.tsx +++ b/src/components/__tests__/Searchbar.test.tsx @@ -1,7 +1,5 @@ -import { Animated } from 'react-native'; - import { expect, it, jest } from '@jest/globals'; -import { act, userEvent } from '@testing-library/react-native'; +import { userEvent } from '@testing-library/react-native'; import { render, screen } from '../../test-utils'; import * as Avatar from '../Avatar/Avatar'; @@ -72,33 +70,6 @@ it('renders clear icon wrapper, which is never target of touch events, if search ).toBe('none'); }); -it('animated value changes correctly', async () => { - const value = new Animated.Value(1); - await render( - - ); - expect(screen.getByTestId('search-bar-container-outer-layer')).toHaveStyle({ - transform: [{ scale: 1 }], - }); - - Animated.timing(value, { - toValue: 1.5, - useNativeDriver: false, - duration: 200, - }).start(); - - await act(() => { - jest.advanceTimersByTime(200); - }); - expect(screen.getByTestId('search-bar-container-outer-layer')).toHaveStyle({ - transform: [{ scale: 1.5 }], - }); -}); - it('defines onClearIconPress action and checks if it is called when close button is pressed', async () => { const onClearIconPressMock = jest.fn(); await render( diff --git a/src/components/__tests__/Snackbar.test.tsx b/src/components/__tests__/Snackbar.test.tsx index 16b118da09..5c30f7cc6e 100644 --- a/src/components/__tests__/Snackbar.test.tsx +++ b/src/components/__tests__/Snackbar.test.tsx @@ -1,9 +1,8 @@ -import { Animated, StyleSheet, Text, View } from 'react-native'; +import { StyleSheet, Text, View } from 'react-native'; import { expect, it, jest } from '@jest/globals'; -import { act } from '@testing-library/react-native'; -import { render, screen } from '../../test-utils'; +import { render } from '../../test-utils'; import { red200, white } from '../../theme/colors'; import Snackbar from '../Snackbar'; @@ -92,33 +91,3 @@ it('renders snackbar with View & Text as a child', async () => { expect(tree).toMatchSnapshot(); }); - -it('animated value changes correctly', async () => { - const value = new Animated.Value(1); - await render( - - Snackbar content - - ); - expect(screen.getByTestId('snack-bar-outer-layer')).toHaveStyle({ - transform: [{ scale: 1 }], - }); - - Animated.timing(value, { - toValue: 1.5, - useNativeDriver: false, - duration: 200, - }).start(); - - await act(() => { - jest.advanceTimersByTime(200); - }); - expect(screen.getByTestId('snack-bar-outer-layer')).toHaveStyle({ - transform: [{ scale: 1.5 }], - }); -}); diff --git a/src/components/__tests__/Surface.test.tsx b/src/components/__tests__/Surface.test.tsx index d0a0437f65..afa12480a0 100644 --- a/src/components/__tests__/Surface.test.tsx +++ b/src/components/__tests__/Surface.test.tsx @@ -1,6 +1,6 @@ -import type { ViewStyle } from 'react-native'; import { StyleSheet } from 'react-native'; import { Platform } from 'react-native'; +import type { ViewStyle } from 'react-native'; import { afterEach, @@ -15,14 +15,14 @@ import { getTheme } from '../../core/theming'; import { render, screen } from '../../test-utils'; import Surface from '../Surface'; +const SPOT_SHADOW_OPACITY = 0.19; +const AMBIENT_SHADOW_OPACITY = 0.039; + type StyleCase = { property: keyof ViewStyle; value: ViewStyle[keyof ViewStyle]; }; -const SPOT_SHADOW_OPACITY = 0.19; -const AMBIENT_SHADOW_OPACITY = 0.039; - afterEach(() => { jest.restoreAllMocks(); }); @@ -30,14 +30,11 @@ afterEach(() => { describe('Surface', () => { it('should properly render passed props', async () => { await render( - + {null} ); - // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. - expect(screen.getByTestId('surface-container').props.pointerEvents).toBe( - 'box-none' - ); + expect(screen.getByLabelText('Surface')).toBeOnTheScreen(); }); describe('on iOS', () => { @@ -46,43 +43,28 @@ describe('Surface', () => { }); const styles = StyleSheet.create({ - absoluteStyles: { - bottom: 10, - end: 20, - left: 30, - position: 'absolute', - right: 40, - start: 50, - top: 60, - }, - innerLayerViewStyle: { - padding: 13, - }, - restStyle: { + surface: { padding: 10, flexDirection: 'row', alignContent: 'center', + margin: 12, + width: 100, }, }); it('should render Surface with appropriate bg color but without shadow, if mode is set to "flat"', async () => { await render( - + {null} ); // @ts-expect-error - expect(screen.getByTestId('surface-test-outer-layer')).not.toHaveStyle({ + expect(screen.getByTestId('surface-test')).not.toHaveStyle({ shadowOpacity: expect.any(Number), }); // @ts-expect-error - expect(screen.getByTestId('surface-test')).not.toHaveStyle({ + expect(screen.getByTestId('surface-test-shadow-layer')).not.toHaveStyle({ shadowOpacity: expect.any(Number), }); expect(screen.getByTestId('surface-test')).toHaveStyle({ @@ -97,14 +79,27 @@ describe('Surface', () => { ); - expect(screen.getByTestId('surface-test-outer-layer')).toHaveStyle({ + expect(screen.getByTestId('surface-test')).toHaveStyle({ shadowOpacity: SPOT_SHADOW_OPACITY, }); - expect(screen.getByTestId('surface-test')).toHaveStyle({ + expect(screen.getByTestId('surface-test-shadow-layer')).toHaveStyle({ shadowOpacity: AMBIENT_SHADOW_OPACITY, }); }); + it('applies styles to the view that contains the children', async () => { + await render( + + {null} + + ); + + expect(screen.getByTestId('surface-test')).toHaveStyle(styles.surface); + expect(screen.getByTestId('surface-test-shadow-layer')).not.toHaveStyle( + styles.surface + ); + }); + it.each([ { property: 'opacity', value: 0.7 }, { property: 'transform', value: [{ scale: 1.02 }] }, @@ -126,25 +121,6 @@ describe('Surface', () => { { property: 'start', value: 1.5 }, { property: 'end', value: 1.6 }, { property: 'flex', value: 6 }, - ] satisfies StyleCase[])( - 'applies $property to outer layer only', - async ({ property, value }) => { - const style = { [property]: value }; - - await render( - - {null} - - ); - - expect(screen.getByTestId('surface-test-outer-layer')).toHaveStyle( - style - ); - expect(screen.getByTestId('surface-test')).not.toHaveStyle(style); - } - ); - - it.each([ { property: 'padding', value: 12 }, { property: 'paddingLeft', value: 12.1 }, { property: 'paddingRight', value: 12.2 }, @@ -155,7 +131,7 @@ describe('Surface', () => { { property: 'borderWidth', value: 2 }, { property: 'borderColor', value: 'black' }, ] satisfies StyleCase[])( - 'applies $property to inner layer only', + 'applies $property to the view that contains the children', async ({ property, value }) => { const style = { [property]: value }; @@ -165,118 +141,32 @@ describe('Surface', () => { ); - expect(screen.getByTestId('surface-test-outer-layer')).not.toHaveStyle( - style - ); expect(screen.getByTestId('surface-test')).toHaveStyle(style); } ); - it.each([ - { property: 'borderRadius', value: 3 }, - { property: 'borderTopLeftRadius', value: 1 }, - { property: 'borderTopRightRadius', value: 2 }, - { property: 'borderBottomLeftRadius', value: 3 }, - { property: 'borderBottomRightRadius', value: 4 }, - { property: 'backgroundColor', value: 'rgb(4, 5, 6)' }, - ] satisfies StyleCase[])( - 'applies $property to every layer', - async ({ property, value }) => { - const style = { [property]: value }; - - await render( - - {null} - - ); - - expect(screen.getByTestId('surface-test-outer-layer')).toHaveStyle( - style - ); - expect(screen.getByTestId('surface-test')).toHaveStyle(style); - } - ); - - describe('outer layer', () => { - it('should not render rest style', async () => { - await render( - - {null} - - ); - - expect(screen.getByTestId('surface-test-outer-layer')).not.toHaveStyle( - styles.restStyle - ); - }); - - it('should render absolute position properties on outer layer', async () => { - await render( - - {null} - - ); - - expect(screen.getByTestId('surface-test-outer-layer')).toHaveStyle( - styles.absoluteStyles - ); - }); - - it('should render absolute position properties on the outer layer', async () => { - await render( - - {null} - - ); - - expect(screen.getByTestId('surface-test-outer-layer')).toHaveStyle( - styles.absoluteStyles - ); - }); - }); - - describe('inner layer', () => { - it('should render inner layer styles on the inner layer', async () => { - await render( - - {null} - - ); - - expect(screen.getByTestId('surface-test')).toHaveStyle( - styles.innerLayerViewStyle - ); - }); - }); - - it('applies backgroundColor to every layer', async () => { + it('applies visual props to every shadow layer', async () => { const backgroundColor = 'rgb(1, 2, 3)'; await render( {null} ); - const style = { backgroundColor }; - expect(screen.getByTestId('surface-test-outer-layer')).toHaveStyle(style); - expect(screen.getByTestId('surface-test')).toHaveStyle(style); - }); - - describe('children wrapper', () => { - it('should render rest styles', async () => { - const combinedStyles = [styles.innerLayerViewStyle, styles.restStyle]; - - await render( - - {null} - - ); - - expect(screen.getByTestId('surface-test')).toHaveStyle(combinedStyles); - }); + const visualStyle = { + backgroundColor, + borderRadius: 4, + borderTopLeftRadius: 8, + }; + expect(screen.getByTestId('surface-test')).toHaveStyle(visualStyle); + expect(screen.getByTestId('surface-test-shadow-layer')).toHaveStyle( + visualStyle + ); }); }); @@ -287,12 +177,7 @@ describe('Surface', () => { it('should render Surface with appropriate bg color but without shadow, if mode is set to "flat"', async () => { await render( - + {null} ); diff --git a/src/components/__tests__/ToggleButton.test.tsx b/src/components/__tests__/ToggleButton.test.tsx index 1ea9e20bae..bd798e8180 100644 --- a/src/components/__tests__/ToggleButton.test.tsx +++ b/src/components/__tests__/ToggleButton.test.tsx @@ -1,10 +1,7 @@ -import { Animated } from 'react-native'; - -import { describe, expect, it, jest } from '@jest/globals'; -import { act } from '@testing-library/react-native'; +import { describe, expect, it } from '@jest/globals'; import { getTheme } from '../../core/theming'; -import { render, screen } from '../../test-utils'; +import { render } from '../../test-utils'; import ToggleButton from '../ToggleButton'; import { getToggleButtonColor } from '../ToggleButton/utils'; @@ -55,36 +52,3 @@ describe('getToggleButtonColor', () => { ); }); }); - -it('animated value changes correctly', async () => { - const value = new Animated.Value(1); - await render( - - ); - expect(screen.getByTestId('toggle-button-container-outer-layer')).toHaveStyle( - { - transform: [{ scale: 1 }], - } - ); - - Animated.timing(value, { - toValue: 1.5, - useNativeDriver: false, - duration: 200, - }).start(); - - await act(() => { - jest.advanceTimersByTime(200); - }); - expect(screen.getByTestId('toggle-button-container-outer-layer')).toHaveStyle( - { - transform: [{ scale: 1.5 }], - } - ); -}); diff --git a/src/components/__tests__/__snapshots__/Badge.test.tsx.snap b/src/components/__tests__/__snapshots__/Badge.test.tsx.snap index 0090bbfbc0..0691cfec5b 100644 --- a/src/components/__tests__/__snapshots__/Badge.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Badge.test.tsx.snap @@ -3,8 +3,18 @@ exports[`renders badge 1`] = ` `; exports[`renders badge as hidden 1`] = ` @@ -80,8 +157,18 @@ exports[`renders badge as hidden 1`] = ` exports[`renders badge in different color 1`] = ` 3 @@ -128,8 +251,18 @@ exports[`renders badge in different color 1`] = ` exports[`renders badge with content 1`] = ` 3 diff --git a/src/components/__tests__/__snapshots__/Banner.test.tsx.snap b/src/components/__tests__/__snapshots__/Banner.test.tsx.snap index 4e75db8cc7..48880b3a64 100644 --- a/src/components/__tests__/__snapshots__/Banner.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Banner.test.tsx.snap @@ -3,127 +3,415 @@ exports[`render visible banner, with custom theme 1`] = ` - + + + - - - - Custom theme - - + ], + ] + } + > + Custom theme + + + - + - + - + + - - - first - - - + undefined, + ], + ], + ] + } + testID="button-text" + > + first + @@ -279,133 +647,341 @@ exports[`render visible banner, with custom theme 1`] = ` exports[`renders hidden banner, without action buttons and without image 1`] = ` - + + - + - + - - - Two line text string with two actions. One to two lines is preferable on mobile. - - - + > + Two line text string with two actions. One to two lines is preferable on mobile. + + @@ -414,149 +990,437 @@ exports[`renders hidden banner, without action buttons and without image 1`] = ` exports[`renders visible banner, with action buttons and with image 1`] = ` - + + + - - - - - + + - Two line text string with two actions. One to two lines is preferable on mobile. - - + ], + ] + } + > + Two line text string with two actions. One to two lines is preferable on mobile. + + + - + - + - + + - - - first - - - + undefined, + ], + ], + ] + } + testID="button-text" + > + first + @@ -712,127 +1656,415 @@ exports[`renders visible banner, with action buttons and with image 1`] = ` exports[`renders visible banner, with action buttons and without image 1`] = ` - + + + - - - - Two line text string with two actions. One to two lines is preferable on mobile. - - + ], + ] + } + > + Two line text string with two actions. One to two lines is preferable on mobile. + + + - + - + - + + - - - first - - - + undefined, + ], + ], + ] + } + testID="button-text" + > + first + - + + - + + - - - - second - - - + undefined, + ], + ], + ] + } + testID="button-text" + > + second + @@ -1140,143 +2627,336 @@ exports[`renders visible banner, with action buttons and without image 1`] = ` exports[`renders visible banner, without action buttons and with image 1`] = ` - + + + - - - - - + + - Two line text string with two actions. One to two lines is preferable on mobile. - - - + > + Two line text string with two actions. One to two lines is preferable on mobile. + + @@ -1285,121 +2965,314 @@ exports[`renders visible banner, without action buttons and with image 1`] = ` exports[`renders visible banner, without action buttons and without image 1`] = ` - + + + - - - - Two line text string with two actions. One to two lines is preferable on mobile. - - - + > + Two line text string with two actions. One to two lines is preferable on mobile. + + diff --git a/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap b/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap index 33855bb467..d7043ac618 100644 --- a/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap @@ -74,42 +74,209 @@ exports[`allows customizing Route's type via generics 1`] = ` + @@ -525,8 +725,18 @@ exports[`allows customizing Route's type via generics 1`] = ` > @@ -757,42 +990,209 @@ exports[`hides labels in non-shifting bottom navigation 1`] = ` + @@ -1209,8 +1642,18 @@ exports[`hides labels in non-shifting bottom navigation 1`] = ` > @@ -1410,8 +1876,18 @@ exports[`hides labels in non-shifting bottom navigation 1`] = ` > @@ -1521,52 +2020,219 @@ exports[`hides labels in shifting bottom navigation 1`] = ` - + + @@ -1973,8 +2672,18 @@ exports[`hides labels in shifting bottom navigation 1`] = ` > @@ -2174,8 +2906,18 @@ exports[`hides labels in shifting bottom navigation 1`] = ` > @@ -2423,42 +3188,209 @@ exports[`renders bottom navigation with getLazy 1`] = ` + @@ -2994,8 +3959,18 @@ exports[`renders bottom navigation with getLazy 1`] = ` > @@ -3315,8 +4313,18 @@ exports[`renders bottom navigation with getLazy 1`] = ` > - - - + + + @@ -3957,8 +5021,18 @@ exports[`renders bottom navigation with getLazy 1`] = ` > @@ -4189,42 +5286,209 @@ exports[`renders bottom navigation with scene animation 1`] = ` + @@ -4714,8 +6011,18 @@ exports[`renders bottom navigation with scene animation 1`] = ` > @@ -4984,8 +6314,18 @@ exports[`renders bottom navigation with scene animation 1`] = ` > @@ -5254,8 +6617,18 @@ exports[`renders bottom navigation with scene animation 1`] = ` > @@ -5524,8 +6920,18 @@ exports[`renders bottom navigation with scene animation 1`] = ` > @@ -5700,42 +7129,209 @@ exports[`renders custom icon and label in non-shifting bottom navigation 1`] = ` + @@ -6086,8 +7715,18 @@ exports[`renders custom icon and label in non-shifting bottom navigation 1`] = ` > @@ -6282,8 +7944,18 @@ exports[`renders custom icon and label in non-shifting bottom navigation 1`] = ` > @@ -6449,42 +8144,209 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = ` + @@ -7000,8 +8928,18 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = ` > @@ -7178,8 +9139,18 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = ` > @@ -7356,8 +9350,18 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = ` > @@ -7500,42 +9527,209 @@ exports[`renders custom icon and label with custom colors in non-shifting bottom + @@ -8071,8 +10298,18 @@ exports[`renders custom icon and label with custom colors in non-shifting bottom > @@ -8392,6 +10652,41 @@ exports[`renders custom icon and label with custom colors in non-shifting bottom > + @@ -9149,8 +11642,18 @@ exports[`renders custom icon and label with custom colors in shifting bottom nav > @@ -9419,8 +11945,18 @@ exports[`renders custom icon and label with custom colors in shifting bottom nav > @@ -9595,42 +12154,209 @@ exports[`renders non-shifting bottom navigation 1`] = ` + @@ -10146,26 +12905,61 @@ exports[`renders non-shifting bottom navigation 1`] = ` ], ] } - > - camera - - - - + camera + + + + @@ -10719,42 +13544,209 @@ exports[`renders shifting bottom navigation 1`] = ` + @@ -11244,8 +14269,18 @@ exports[`renders shifting bottom navigation 1`] = ` > @@ -11514,8 +14572,18 @@ exports[`renders shifting bottom navigation 1`] = ` > @@ -11784,8 +14875,18 @@ exports[`renders shifting bottom navigation 1`] = ` > @@ -12054,8 +15178,18 @@ exports[`renders shifting bottom navigation 1`] = ` > diff --git a/src/components/__tests__/__snapshots__/Button.test.tsx.snap b/src/components/__tests__/__snapshots__/Button.test.tsx.snap index bbc1fff1be..434dd772ef 100644 --- a/src/components/__tests__/__snapshots__/Button.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Button.test.tsx.snap @@ -3,32 +3,97 @@ exports[`renders button with an accessibility hint 1`] = ` - + - + + - - - Button with accessibility hint - - + ], + ] + } + testID="button-text" + > + Button with accessibility hint + @@ -155,34 +323,99 @@ exports[`renders button with an accessibility hint 1`] = ` exports[`renders button with an accessibility label 1`] = ` - + - + + - - - Button with accessibility label - - + ], + ] + } + testID="button-text" + > + Button with accessibility label + @@ -309,150 +645,318 @@ exports[`renders button with an accessibility label 1`] = ` exports[`renders button with button color 1`] = ` - - + + + - - - Custom Button - - + ], + ] + } + testID="button-text" + > + Custom Button + @@ -461,32 +965,97 @@ exports[`renders button with button color 1`] = ` exports[`renders button with color 1`] = ` - + - + + - - - Custom Button - - + ], + ] + } + testID="button-text" + > + Custom Button + @@ -613,32 +1285,97 @@ exports[`renders button with color 1`] = ` exports[`renders button with custom testID 1`] = ` - + - + + - - - Button with custom testID - - + ], + ] + } + testID="custom:testID-text" + > + Button with custom testID + @@ -765,199 +1605,367 @@ exports[`renders button with custom testID 1`] = ` exports[`renders button with icon 1`] = ` + } + jestInlineStyle={ + [ + { + "transitionDuration": 150, + "transitionProperty": [ + "shadowOpacity", + "shadowOffset", + "shadowRadius", + ], + "transitionTimingFunction": "ease-in-out", + }, + [ + { + "borderStyle": "solid", + "minWidth": 64, + }, + undefined, + { + "borderColor": "transparent", + "borderWidth": 0, + }, + undefined, + ], + { + "shadowColor": "rgba(0, 0, 0, 1)", + "shadowOffset": { + "height": 0, + "width": 0, + }, + "shadowOpacity": 0, + "shadowRadius": 0, + }, + ] + } + style={ + [ + {}, + { + "borderStyle": "solid", + "minWidth": 64, + }, + undefined, + { + "borderColor": "transparent", + "borderWidth": 0, + }, + undefined, + { + "backgroundColor": "transparent", + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderCurve": undefined, + "borderEndEndRadius": undefined, + "borderEndStartRadius": undefined, + "borderRadius": 20, + "borderStartEndRadius": undefined, + "borderStartStartRadius": undefined, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "shadowColor": "rgba(0, 0, 0, 1)", + "shadowOffset": { + "height": 0, + "width": 0, + }, + "shadowOpacity": 0, + "shadowRadius": 0, + }, + ] + } + testID="button-container" +> + - - - camera - - - + camera + + + - Icon Button - - + ], + ] + } + testID="button-text" + > + Icon Button + @@ -966,32 +1974,97 @@ exports[`renders button with icon 1`] = ` exports[`renders button with icon in reverse order 1`] = ` - + - + + - - - chevron-right - - - + chevron-right + + + - Right Icon - - + ], + ] + } + testID="button-text" + > + Right Icon + @@ -1169,32 +2345,97 @@ exports[`renders button with icon in reverse order 1`] = ` exports[`renders contained contained with mode 1`] = ` - + - + + - - - Contained Button - - + ], + ] + } + testID="button-text" + > + Contained Button + @@ -1322,32 +2666,97 @@ exports[`renders contained contained with mode 1`] = ` exports[`renders disabled button 1`] = ` - + - + + - - - Disabled Button - - + ], + ] + } + testID="button-text" + > + Disabled Button + @@ -1474,32 +2986,97 @@ exports[`renders disabled button 1`] = ` exports[`renders loading button 1`] = ` - + - + + @@ -1610,13 +3305,13 @@ exports[`renders loading button 1`] = ` collapsable={false} style={ { - "alignItems": "center", - "bottom": 0, - "justifyContent": "center", - "left": 0, - "position": "absolute", - "right": 0, - "top": 0, + "height": 18, + "transform": [ + { + "rotate": "45deg", + }, + ], + "width": 18, } } > @@ -1624,12 +3319,8 @@ exports[`renders loading button 1`] = ` collapsable={false} style={ { - "height": 18, - "transform": [ - { - "rotate": "45deg", - }, - ], + "height": 9, + "overflow": "hidden", "width": 18, } } @@ -1638,8 +3329,15 @@ exports[`renders loading button 1`] = ` collapsable={false} style={ { - "height": 9, - "overflow": "hidden", + "height": 18, + "transform": [ + { + "translateY": 0, + }, + { + "rotate": "-165deg", + }, + ], "width": 18, } } @@ -1648,15 +3346,8 @@ exports[`renders loading button 1`] = ` collapsable={false} style={ { - "height": 18, - "transform": [ - { - "translateY": 0, - }, - { - "rotate": "-165deg", - }, - ], + "height": 9, + "overflow": "hidden", "width": 18, } } @@ -1665,40 +3356,44 @@ exports[`renders loading button 1`] = ` collapsable={false} style={ { - "height": 9, - "overflow": "hidden", + "borderColor": "rgba(103, 80, 164, 1)", + "borderRadius": 9, + "borderWidth": 1.8, + "height": 18, "width": 18, } } - > - - + /> + + @@ -1706,12 +3401,9 @@ exports[`renders loading button 1`] = ` collapsable={false} style={ { - "height": 18, - "transform": [ - { - "rotate": "45deg", - }, - ], + "height": 9, + "overflow": "hidden", + "top": 9, "width": 18, } } @@ -1720,9 +3412,15 @@ exports[`renders loading button 1`] = ` collapsable={false} style={ { - "height": 9, - "overflow": "hidden", - "top": 9, + "height": 18, + "transform": [ + { + "translateY": -9, + }, + { + "rotate": "345deg", + }, + ], "width": 18, } } @@ -1731,15 +3429,8 @@ exports[`renders loading button 1`] = ` collapsable={false} style={ { - "height": 18, - "transform": [ - { - "translateY": -9, - }, - { - "rotate": "345deg", - }, - ], + "height": 9, + "overflow": "hidden", "width": 18, } } @@ -1748,80 +3439,69 @@ exports[`renders loading button 1`] = ` collapsable={false} style={ { - "height": 9, - "overflow": "hidden", + "borderColor": "rgba(103, 80, 164, 1)", + "borderRadius": 9, + "borderWidth": 1.8, + "height": 18, "width": 18, } } - > - - + /> - + - Loading Button - - + ], + ] + } + testID="button-text" + > + Loading Button + @@ -1830,32 +3510,97 @@ exports[`renders loading button 1`] = ` exports[`renders outlined button with mode 1`] = ` - + - + + - - - Outlined Button - - + ], + ] + } + testID="button-text" + > + Outlined Button + @@ -1983,32 +3831,97 @@ exports[`renders outlined button with mode 1`] = ` exports[`renders text button by default 1`] = ` - + - + + - - - Text Button - - + ], + ] + } + testID="button-text" + > + Text Button + @@ -2135,32 +4151,97 @@ exports[`renders text button by default 1`] = ` exports[`renders text button with mode 1`] = ` - + - + + - - - Text Button - - + ], + ] + } + testID="button-text" + > + Text Button + diff --git a/src/components/__tests__/__snapshots__/Chip.test.tsx.snap b/src/components/__tests__/__snapshots__/Chip.test.tsx.snap index 7bf18dde0e..28c7006561 100644 --- a/src/components/__tests__/__snapshots__/Chip.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Chip.test.tsx.snap @@ -3,32 +3,101 @@ exports[`renders chip with close button 1`] = ` - + - + + - - - information - - - Example Chip + information - - - - - - close - - - + { + "marginLeft": 8, + "marginRight": 0, + }, + undefined, + ], + ], + ] + } + > + Example Chip + - -`; - -exports[`renders chip with custom close button 1`] = ` - - - - information - - - - Example Chip + close - - - - - arrow-down - - - - `; -exports[`renders chip with icon 1`] = ` +exports[`renders chip with custom close button 1`] = ` - + - + + - - - information - - - + information + + + - Example Chip - - + ], + ] + } + > + Example Chip + - -`; - -exports[`renders chip with onPress 1`] = ` - - Example Chip + arrow-down @@ -961,35 +940,104 @@ exports[`renders chip with onPress 1`] = ` `; -exports[`renders outlined disabled chip 1`] = ` +exports[`renders chip with icon 1`] = ` - + - + + + information + + + - Example Chip - - + ], + ] + } + > + Example Chip + `; -exports[`renders selected chip 1`] = ` +exports[`renders chip with onPress 1`] = ` - + - + + + + Example Chip + + + + +`; + +exports[`renders outlined disabled chip 1`] = ` + + + + - + Example Chip + + + + +`; + +exports[`renders selected chip 1`] = ` + + + + + - - - check - - - + check + + + - Example Chip - - + ], + ] + } + > + Example Chip + diff --git a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap index e9bc774f78..38f643c1c0 100644 --- a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap @@ -381,281 +381,299 @@ exports[`DataTable.Pagination renders data table pagination 1`] = ` > - + - + - - - chevron-left - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + chevron-left + - + - + - - - chevron-right - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + chevron-right + @@ -719,561 +737,597 @@ exports[`DataTable.Pagination renders data table pagination with fast-forward bu > - + - + - - - page-first - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + page-first + - + - + - - - chevron-left - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + chevron-left + - - + + - - - chevron-right - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + chevron-right + - + - + - - - page-last - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + page-last + @@ -1337,281 +1391,299 @@ exports[`DataTable.Pagination renders data table pagination with label 1`] = ` > - + - + - - - chevron-left - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + chevron-left + - + - + - - - chevron-right - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + chevron-right + @@ -1683,33 +1755,59 @@ exports[`DataTable.Pagination renders data table pagination with options select > - - + + + - - - menu-down - - - + menu-down + + + - 2 - - + ], + ] + } + testID="button-text" + > + 2 + @@ -1923,561 +2167,597 @@ exports[`DataTable.Pagination renders data table pagination with options select > - + - + - - - page-first - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + page-first + - + - + - - - chevron-left - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + chevron-left + - + - + - - - chevron-right - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + chevron-right + - + - + - - - page-last - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + page-last + diff --git a/src/components/__tests__/__snapshots__/FAB.test.tsx.snap b/src/components/__tests__/__snapshots__/FAB.test.tsx.snap index cec33bb556..00f077c522 100644 --- a/src/components/__tests__/__snapshots__/FAB.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/FAB.test.tsx.snap @@ -2,15 +2,80 @@ exports[`renders FAB large size 1`] = ` + + - -`; - + style={ + [ + { + "borderWidth": 3, + "bottom": -5, + "left": -5, + "pointerEvents": "none", + "position": "absolute", + "right": -5, + "top": -5, + }, + { + "borderColor": "rgba(98, 91, 113, 1)", + }, + { + "borderRadius": 25, + "opacity": 0, + }, + ] + } + /> + +`; + exports[`renders FAB transitioning to not visible 1`] = ` + - + + + + + - + + @@ -1353,6 +3227,36 @@ exports[`renders FAB with default props 1`] = ` + + +`; + +exports[`renders FAB with secondary variant 1`] = ` + + - -`; - -exports[`renders FAB with secondary variant 1`] = ` - - + + +`; + +exports[`renders FAB with tonalSecondary variant 1`] = ` + - -`; - -exports[`renders FAB with tonalSecondary variant 1`] = ` - + + + - + + + - + + + + - + + - + + - + + + + + - + + - + - + - - - camera - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + camera + @@ -145,140 +154,149 @@ exports[`renders disabled icon button 1`] = ` exports[`renders icon button by default 1`] = ` - + - + - - - camera - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + camera + @@ -287,140 +305,149 @@ exports[`renders icon button by default 1`] = ` exports[`renders icon button with color 1`] = ` - + - + - - - camera - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + camera + @@ -429,140 +456,149 @@ exports[`renders icon button with color 1`] = ` exports[`renders icon button with size 1`] = ` - + - + - - - camera - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + camera + @@ -571,173 +607,182 @@ exports[`renders icon button with size 1`] = ` exports[`renders icon change animated 1`] = ` - + - + + - - - - camera - - + camera + diff --git a/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap b/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap index f06b87045f..c3d0a5be5c 100644 --- a/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap @@ -119,32 +119,101 @@ exports[`renders list item with custom description 1`] = ` - + - + + - - - file-pdf-box - - - + file-pdf-box + + + - DOCS.pdf - - + ], + ] + } + > + DOCS.pdf + diff --git a/src/components/__tests__/__snapshots__/Menu.test.tsx.snap b/src/components/__tests__/__snapshots__/Menu.test.tsx.snap index 3e027106ae..c2f0eb2e19 100644 --- a/src/components/__tests__/__snapshots__/Menu.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Menu.test.tsx.snap @@ -16,32 +16,97 @@ exports[`renders menu with content styles 1`] = ` > - + - + + - - - Open menu - - + ], + ] + } + testID="button-text" + > + Open menu + @@ -244,69 +412,305 @@ exports[`renders menu with content styles 1`] = ` > - + - + + - + + + + + Open menu + + + + + + +`; + +exports[`renders visible menu 1`] = ` +<> + + + + + testID="button-container-shadow-layer" + /> - -`; - -exports[`renders visible menu 1`] = ` -<> - - - - - - - - Open menu - - - - - - - - + + - + - + + - - - - magnify - - - + magnify + - + + + @@ -245,13 +429,13 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "alignItems": "center", - "bottom": 0, - "justifyContent": "center", - "left": 0, - "position": "absolute", - "right": 0, - "top": 0, + "height": 24, + "transform": [ + { + "rotate": "45deg", + }, + ], + "width": 24, } } > @@ -259,12 +443,8 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "height": 24, - "transform": [ - { - "rotate": "45deg", - }, - ], + "height": 12, + "overflow": "hidden", "width": 24, } } @@ -273,8 +453,15 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "height": 12, - "overflow": "hidden", + "height": 24, + "transform": [ + { + "translateY": 0, + }, + { + "rotate": "-165deg", + }, + ], "width": 24, } } @@ -283,15 +470,8 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "height": 24, - "transform": [ - { - "translateY": 0, - }, - { - "rotate": "-165deg", - }, - ], + "height": 12, + "overflow": "hidden", "width": 24, } } @@ -300,40 +480,44 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "height": 12, - "overflow": "hidden", + "borderColor": "rgba(103, 80, 164, 1)", + "borderRadius": 12, + "borderWidth": 2.4, + "height": 24, "width": 24, } } - > - - + /> + + @@ -341,12 +525,9 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "height": 24, - "transform": [ - { - "rotate": "45deg", - }, - ], + "height": 12, + "overflow": "hidden", + "top": 12, "width": 24, } } @@ -355,9 +536,15 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "height": 12, - "overflow": "hidden", - "top": 12, + "height": 24, + "transform": [ + { + "translateY": -12, + }, + { + "rotate": "345deg", + }, + ], "width": 24, } } @@ -366,15 +553,8 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "height": 24, - "transform": [ - { - "translateY": -12, - }, - { - "rotate": "345deg", - }, - ], + "height": 12, + "overflow": "hidden", "width": 24, } } @@ -383,25 +563,14 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "height": 12, - "overflow": "hidden", + "borderColor": "rgba(103, 80, 164, 1)", + "borderRadius": 12, + "borderWidth": 2.4, + "height": 24, "width": 24, } } - > - - + /> @@ -415,30 +584,87 @@ exports[`activity indicator snapshot test 1`] = ` exports[`renders with placeholder 1`] = ` - + - + + - - - - magnify - - - + magnify + - + + + - - - - - close - - - + close + @@ -788,30 +1135,87 @@ exports[`renders with placeholder 1`] = ` exports[`renders with text 1`] = ` - + - + + - - - - magnify - - - + magnify + - + + + - - - - - close - - - + close + diff --git a/src/components/__tests__/__snapshots__/Snackbar.test.tsx.snap b/src/components/__tests__/__snapshots__/Snackbar.test.tsx.snap index 76d4857c89..c57a68e66e 100644 --- a/src/components/__tests__/__snapshots__/Snackbar.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Snackbar.test.tsx.snap @@ -21,69 +21,244 @@ exports[`renders snackbar with Text as a child 1`] = ` } > - + - - - - Snackbar content - - + } + jestInlineStyle={ + [ + { + "bottom": 0, + "left": 0, + "position": "absolute", + "right": 0, + "top": 0, + }, + { + "pointerEvents": "none", + }, + { + "transitionDuration": 150, + "transitionProperty": [ + "shadowOpacity", + "shadowOffset", + "shadowRadius", + ], + "transitionTimingFunction": "ease-in-out", + }, + { + "shadowColor": "rgba(0, 0, 0, 1)", + "shadowOffset": { + "height": 0, + "width": 0, + }, + "shadowOpacity": 0.039, + "shadowRadius": 0.75, + }, + ] + } + style={ + [ + { + "bottom": 0, + "left": 0, + "position": "absolute", + "right": 0, + "top": 0, + }, + { + "pointerEvents": "none", + }, + {}, + { + "backgroundColor": "rgba(50, 47, 53, 1)", + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderCurve": undefined, + "borderEndEndRadius": undefined, + "borderEndStartRadius": undefined, + "borderRadius": 4, + "borderStartEndRadius": undefined, + "borderStartStartRadius": undefined, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "shadowColor": "rgba(0, 0, 0, 1)", + "shadowOffset": { + "height": 0, + "width": 0, + }, + "shadowOpacity": 0.039, + "shadowRadius": 0.75, + }, + ] + } + /> + + + + Snackbar content + @@ -109,94 +284,269 @@ exports[`renders snackbar with View & Text as a child 1`] = ` } > - + + - + - + "alignItems": "center", + "flexDirection": "row", + } + } + > - - + - Error Message which is veryyyyyyyyyyyy longggggggg Error Message which is veryyyyyyyyyyyy longggggggg - - + } + > + Error Message which is veryyyyyyyyyyyy longggggggg Error Message which is veryyyyyyyyyyyy longggggggg + @@ -223,109 +573,380 @@ exports[`renders snackbar with action button 1`] = ` } > + + + > + Snackbar content + - - Snackbar content - - - + - + - + + - - - Undo - - - + undefined, + ], + ], + ] + } + testID="button-text" + > + Undo + @@ -494,87 +1195,262 @@ exports[`renders snackbar with content 1`] = ` } > - + - + - Snackbar content - - + ], + ] + } + > + Snackbar content + `; diff --git a/src/components/__tests__/__snapshots__/Switch.test.tsx.snap b/src/components/__tests__/__snapshots__/Switch.test.tsx.snap index 3589a26ee9..632c74182b 100644 --- a/src/components/__tests__/__snapshots__/Switch.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Switch.test.tsx.snap @@ -96,6 +96,42 @@ exports[`Switch render renders disabled off 1`] = ` - - + + - + - + - - - magnify - - + ], + ] + } + > + magnify + - + - + - - - close - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + close + @@ -572,6 +742,39 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is } /> + - + - - - magnify - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + magnify + - + - + - - - close - - + ], + ] + } + > + close + @@ -1074,6 +1414,39 @@ exports[`renders filled TextInput with label and value 1`] = ` } /> + - - + - + - - - magnify - - + ], + ] + } + > + magnify + - + - + - - - close - - + ], + ] + } + > + close + @@ -1753,6 +2345,37 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i /> - + - + - - - magnify - - + ], + ] + } + > + magnify + - + - + - - - close - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + close + @@ -2236,6 +2972,37 @@ exports[`renders outlined TextInput with label and value 1`] = ` /> - + - + + - - - heart - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + heart + @@ -143,138 +174,169 @@ exports[`renders disabled toggle button 1`] = ` exports[`renders toggle button 1`] = ` - + - + + - - - heart - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + heart + @@ -283,138 +345,169 @@ exports[`renders toggle button 1`] = ` exports[`renders unchecked toggle button 1`] = ` - + - + + - - - heart - - + ], + ] + } + > + heart + diff --git a/src/theme/tokens/sys/elevation.ts b/src/theme/tokens/sys/elevation.ts index 18e92dcb63..9a1181ecbe 100644 --- a/src/theme/tokens/sys/elevation.ts +++ b/src/theme/tokens/sys/elevation.ts @@ -1,34 +1,11 @@ // M3 elevation tokens and shadow builder per spec: // https://m3.material.io/styles/elevation/tokens -import { - Animated, - Platform, - type ColorValue, - type ViewStyle, - type Animated as AnimatedNS, -} from 'react-native'; +import { Platform, type ColorValue, type ViewStyle } from 'react-native'; import color from 'color'; -import { isAnimatedValue } from '../../../utils/animations'; -import type { Elevation, ThemeElevation } from '../../types'; - -type AnimatedNativeShadowStyle = { - shadowColor: ColorValue; - shadowOffset: { - width: AnimatedNS.Value; - height: AnimatedNS.AnimatedInterpolation; - }; - shadowOpacity: AnimatedNS.AnimatedInterpolation; - shadowRadius: AnimatedNS.AnimatedInterpolation; -}; - -type AnimatedBoxShadowStyle = { - boxShadow: AnimatedNS.AnimatedInterpolation; -}; - -type AnimatedShadowStyle = AnimatedNativeShadowStyle | AnimatedBoxShadowStyle; +import type { ThemeElevation } from '../../types'; export const defaultElevation: ThemeElevation = { level0: 0, @@ -39,8 +16,6 @@ export const defaultElevation: ThemeElevation = { level5: 5, }; -export const elevationInputRange: Elevation[] = Object.values(defaultElevation); - export const androidElevationLevels = [0, 1, 3, 6, 8, 12]; /** @@ -110,7 +85,7 @@ const IOS_SHADOW_RADIUS_FACTOR = 0.5; const getShadowRadius = (blurRadius: number[]) => blurRadius.map((radius) => round(radius * IOS_SHADOW_RADIUS_FACTOR)); -export const shadowLayers = [ +const shadowLayers = [ { height: androidElevationLevels.map((dp) => round( @@ -130,94 +105,58 @@ export const shadowLayers = [ }, ]; -const getShadowColor = (shadowColor: ColorValue, shadowOpacity: number) => { - if (typeof shadowColor !== 'string') { - throw new Error( - `Expected a string shadow color on Web, but received a ${typeof shadowColor}.` - ); - } - - return color(shadowColor).alpha(shadowOpacity).rgb().string(); +type NativeShadowStyle = { + shadowColor: ColorValue; + shadowOpacity: number; + shadowOffset: { + width: number; + height: number; + }; + shadowRadius: number; }; -const getBoxShadowValue = (elevation: number, layerColors: readonly string[]) => - shadowLayers - .map( - (layer, index) => - `0px ${layer.height[elevation]}px ${layer.blurRadius[elevation]}px ${layerColors[index]}` - ) - .join(', '); +type ShadowStyle = + | NativeShadowStyle + | { boxShadow: NonNullable }; -export function shadow(elevation: number, shadowColor: ColorValue): ViewStyle; -// eslint-disable-next-line no-redeclare export function shadow( - elevation: Animated.Value, + elevation: number, shadowColor: ColorValue -): AnimatedShadowStyle; -// eslint-disable-next-line no-redeclare -export function shadow( - elevation: number | Animated.Value, - shadowColor: ColorValue -): ViewStyle | AnimatedShadowStyle; -// eslint-disable-next-line no-redeclare -export function shadow( - elevation: number | Animated.Value = 0, - shadowColor: ColorValue -): ViewStyle | AnimatedShadowStyle { +): [ShadowStyle, NativeShadowStyle | undefined] { if (Platform.OS === 'web') { - const layerColors = shadowLayers.map((layer) => - getShadowColor(shadowColor, layer.shadowOpacity) - ); - - if (isAnimatedValue(elevation)) { - return { - boxShadow: elevation.interpolate({ - inputRange: elevationInputRange, - outputRange: elevationInputRange.map((value) => - getBoxShadowValue(value, layerColors) - ), - }), - }; + if (typeof shadowColor !== 'string') { + throw new Error( + `Expected a string shadow color on Web, but received a ${typeof shadowColor}.` + ); } - return { - boxShadow: getBoxShadowValue(elevation, layerColors), - }; - } - - // For a single view, we can only draw one shadow - // So we pick the spot shadow, as it shows the depth - const [spotShadow] = shadowLayers; - - if (isAnimatedValue(elevation)) { - return { - shadowColor, - shadowOffset: { - width: new Animated.Value(0), - height: elevation.interpolate({ - inputRange: elevationInputRange, - outputRange: spotShadow.height, - }), + return [ + { + boxShadow: shadowLayers + .map( + (layer) => + `0px ${layer.height[elevation]}px ${layer.blurRadius[elevation]}px ${color( + shadowColor + ) + .alpha(layer.shadowOpacity) + .rgb() + .string()}` + ) + .join(', '), }, - shadowOpacity: elevation.interpolate({ - inputRange: [0, 1], - outputRange: [0, spotShadow.shadowOpacity], - extrapolate: 'clamp', - }), - shadowRadius: elevation.interpolate({ - inputRange: elevationInputRange, - outputRange: spotShadow.shadowRadius, - }), - }; + undefined, + ]; } - return { + const [spotShadow, ambientShadow] = shadowLayers.map((layer) => ({ shadowColor, - shadowOpacity: elevation ? spotShadow.shadowOpacity : 0, + shadowOpacity: elevation ? layer.shadowOpacity : 0, shadowOffset: { width: 0, - height: spotShadow.height[elevation], + height: layer.height[elevation], }, - shadowRadius: spotShadow.shadowRadius[elevation], - }; + shadowRadius: layer.shadowRadius[elevation], + })); + + return [spotShadow, ambientShadow]; } diff --git a/src/utils/animations.ts b/src/utils/animations.ts deleted file mode 100644 index 7af142bbf3..0000000000 --- a/src/utils/animations.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Animated } from 'react-native'; - -export const isAnimatedValue = ( - it: number | string | Animated.AnimatedInterpolation -): it is Animated.Value => it instanceof Animated.Value;