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..4add0da5c0 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,131 @@ const styles = StyleSheet.create({
-### children (required) +### backgroundColor
- + + +
+ +### borderRadius + +
+ + + +
+ +### borderBottomEndRadius + +
+ + + +
+ +### borderBottomLeftRadius + +
+ + + +
+ +### borderBottomRightRadius + +
+ + + +
+ +### borderBottomStartRadius + +
+ + + +
+ +### borderEndEndRadius + +
+ + + +
+ +### borderEndStartRadius + +
+ + + +
+ +### borderStartEndRadius + +
+ + + +
+ +### borderStartStartRadius + +
+ + + +
+ +### borderTopEndRadius + +
+ + + +
+ +### borderTopLeftRadius + +
+ + + +
+ +### borderTopRightRadius + +
+ + + +
+ +### borderTopStartRadius + +
+ + + +
+ +### borderCurve + +
+ + + +
+ +### transitionDuration + +
+ +
@@ -92,6 +212,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..e488b101ef 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -4,8 +4,162 @@ 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 component style props no longer support overriding their background color or border radius: + +- `Banner` +- `Button` +- `Card` +- `Chip` +- `Dialog` +- `Menu`: `contentStyle` +- `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. + +The `style.elevation` property is no longer supported. Use the `elevated` prop to control Appbar elevation. + +### Surface + +- The `elevation` prop no longer accepts a React Native `Animated.Value`. Any `elevation` changes are animated automatically. +- The `style` prop no longer configures elevation, background color, or border radius. Use these props instead: + - `elevation` + - `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 any border radius property. 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..b223c9c0a8 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,23 @@ "style": { "required": false, "tsType": { - "name": "Animated.WithAnimatedValue", + "name": "StyleProp", "elements": [ { - "name": "StyleProp", + "name": "Omit", "elements": [ { "name": "ViewStyle" + }, + { + "name": "literal", + "value": "'elevation'" } ], - "raw": "StyleProp" + "raw": "Omit" } ], - "raw": "Animated.WithAnimatedValue>" + "raw": "StyleProp" }, "description": "" } @@ -304,19 +312,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 +424,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 +702,13 @@ "style": { "required": false, "tsType": { - "name": "Animated.WithAnimatedValue", + "name": "StyleProp", "elements": [ { - "name": "StyleProp", - "elements": [ - { - "name": "ViewStyle" - } - ], - "raw": "StyleProp" + "name": "AppbarStyle" } ], - "raw": "Animated.WithAnimatedValue>" + "raw": "StyleProp" }, "description": "" }, @@ -1032,10 +1034,16 @@ "name": "StyleProp", "elements": [ { - "name": "TextStyle" + "name": "AnimatedStyle", + "elements": [ + { + "name": "TextStyle" + } + ], + "raw": "AnimatedStyle" } ], - "raw": "StyleProp" + "raw": "StyleProp>" }, "description": "" }, @@ -1147,37 +1155,7 @@ "elevation": { "required": false, "tsType": { - "name": "union", - "raw": "0 | 1 | 2 | 3 | 4 | 5 | Animated.Value", - "elements": [ - { - "name": "literal", - "value": "0" - }, - { - "name": "literal", - "value": "1" - }, - { - "name": "literal", - "value": "2" - }, - { - "name": "literal", - "value": "3" - }, - { - "name": "literal", - "value": "4" - }, - { - "name": "literal", - "value": "5" - }, - { - "name": "Animated.Value" - } - ] + "name": "Elevation" }, "description": "@supported Available in v5.x with theme version 3\nChanges Banner shadow and background on iOS and Android.", "defaultValue": { @@ -1195,19 +1173,13 @@ "style": { "required": false, "tsType": { - "name": "Animated.WithAnimatedValue", + "name": "StyleProp", "elements": [ { - "name": "StyleProp", - "elements": [ - { - "name": "ViewStyle" - } - ], - "raw": "StyleProp" + "name": "SurfaceStyle" } ], - "raw": "Animated.WithAnimatedValue>" + "raw": "StyleProp" }, "description": "" }, @@ -1234,7 +1206,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 +1245,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 +3254,13 @@ "style": { "required": false, "tsType": { - "name": "Animated.WithAnimatedValue", + "name": "StyleProp", "elements": [ { - "name": "StyleProp", - "elements": [ - { - "name": "ViewStyle" - } - ], - "raw": "StyleProp" + "name": "SurfaceStyle" } ], - "raw": "Animated.WithAnimatedValue>" + "raw": "StyleProp" }, "description": "" }, @@ -3449,37 +3471,7 @@ "elevation": { "required": false, "tsType": { - "name": "union", - "raw": "0 | 1 | 2 | 3 | 4 | 5 | Animated.Value", - "elements": [ - { - "name": "literal", - "value": "0" - }, - { - "name": "literal", - "value": "1" - }, - { - "name": "literal", - "value": "2" - }, - { - "name": "literal", - "value": "3" - }, - { - "name": "literal", - "value": "4" - }, - { - "name": "literal", - "value": "5" - }, - { - "name": "Animated.Value" - } - ] + "name": "Elevation" }, "description": "Changes Card shadow and background on iOS and Android.", "defaultValue": { @@ -3503,19 +3495,13 @@ "style": { "required": false, "tsType": { - "name": "Animated.WithAnimatedValue", + "name": "StyleProp", "elements": [ { - "name": "StyleProp", - "elements": [ - { - "name": "ViewStyle" - } - ], - "raw": "StyleProp" + "name": "SurfaceStyle" } ], - "raw": "Animated.WithAnimatedValue>" + "raw": "StyleProp" }, "description": "" }, @@ -3543,6 +3529,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 +4589,13 @@ "style": { "required": false, "tsType": { - "name": "Animated.WithAnimatedValue", + "name": "StyleProp", "elements": [ { - "name": "StyleProp", - "elements": [ - { - "name": "ViewStyle" - } - ], - "raw": "StyleProp" + "name": "SurfaceStyle" } ], - "raw": "Animated.WithAnimatedValue>" + "raw": "StyleProp" }, "description": "" }, @@ -4646,6 +4639,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 +5279,13 @@ "style": { "required": false, "tsType": { - "name": "Animated.WithAnimatedValue", + "name": "StyleProp", "elements": [ { - "name": "StyleProp", - "elements": [ - { - "name": "ViewStyle" - } - ], - "raw": "StyleProp" + "name": "SurfaceStyle" } ], - "raw": "Animated.WithAnimatedValue>" + "raw": "StyleProp" }, "description": "" }, @@ -6124,10 +6124,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 +6345,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 +7941,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 +9071,13 @@ "contentStyle": { "required": false, "tsType": { - "name": "Animated.WithAnimatedValue", + "name": "StyleProp", "elements": [ { - "name": "StyleProp", - "elements": [ - { - "name": "ViewStyle" - } - ], - "raw": "StyleProp" + "name": "SurfaceStyle" } ], - "raw": "Animated.WithAnimatedValue>" + "raw": "StyleProp" }, "description": "Style of menu's inner content." }, @@ -9375,10 +9381,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 +9463,42 @@ "contentContainerStyle": { "required": false, "tsType": { - "name": "Animated.WithAnimatedValue", + "name": "StyleProp", "elements": [ { - "name": "StyleProp", - "elements": [ - { - "name": "ViewStyle" - } - ], - "raw": "StyleProp" + "name": "SurfaceStyle" } ], - "raw": "Animated.WithAnimatedValue>" + "raw": "StyleProp" }, - "description": "Style for the content of the modal" + "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": "Elevation level of the modal content. Defaults to level 1." }, "style": { "required": false, @@ -10546,37 +10573,7 @@ "elevation": { "required": false, "tsType": { - "name": "union", - "raw": "0 | 1 | 2 | 3 | 4 | 5 | Animated.Value", - "elements": [ - { - "name": "literal", - "value": "0" - }, - { - "name": "literal", - "value": "1" - }, - { - "name": "literal", - "value": "2" - }, - { - "name": "literal", - "value": "3" - }, - { - "name": "literal", - "value": "4" - }, - { - "name": "literal", - "value": "5" - }, - { - "name": "Animated.Value" - } - ] + "name": "Elevation" }, "description": "@supported Available in v5.x with theme version 3\nChanges Searchbar shadow and background on iOS and Android.", "defaultValue": { @@ -10600,19 +10597,13 @@ "style": { "required": false, "tsType": { - "name": "Animated.WithAnimatedValue", + "name": "StyleProp", "elements": [ { - "name": "StyleProp", - "elements": [ - { - "name": "ViewStyle" - } - ], - "raw": "StyleProp" + "name": "SurfaceStyle" } ], - "raw": "Animated.WithAnimatedValue>" + "raw": "StyleProp" }, "description": "" }, @@ -11018,37 +11009,7 @@ "elevation": { "required": false, "tsType": { - "name": "union", - "raw": "0 | 1 | 2 | 3 | 4 | 5 | Animated.Value", - "elements": [ - { - "name": "literal", - "value": "0" - }, - { - "name": "literal", - "value": "1" - }, - { - "name": "literal", - "value": "2" - }, - { - "name": "literal", - "value": "3" - }, - { - "name": "literal", - "value": "4" - }, - { - "name": "literal", - "value": "5" - }, - { - "name": "Animated.Value" - } - ] + "name": "Elevation" }, "description": "@supported Available in v5.x with theme version 3\nChanges Snackbar shadow and background on iOS and Android.", "defaultValue": { @@ -11092,19 +11053,13 @@ "style": { "required": false, "tsType": { - "name": "Animated.WithAnimatedValue", + "name": "StyleProp", "elements": [ { - "name": "StyleProp", - "elements": [ - { - "name": "ViewStyle" - } - ], - "raw": "StyleProp" + "name": "SurfaceStyle" } ], - "raw": "Animated.WithAnimatedValue>" + "raw": "StyleProp" }, "description": "" }, @@ -11145,54 +11100,186 @@ "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": "ColorValue" }, - "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.", + "defaultValue": { + "value": "'continuous'", + "computed": false + } + }, + "transitionDuration": { + "required": false, + "tsType": { + "name": "number" + }, + "description": "Duration of the background, elevation, and shadow transitions in\nmilliseconds." }, "style": { "required": false, "tsType": { - "name": "Animated.WithAnimatedValue", + "name": "StyleProp", "elements": [ { - "name": "StyleProp", + "name": "AnimatedStyle", "elements": [ { - "name": "ViewStyle" + "name": "Omit", + "elements": [ + { + "name": "ViewStyle" + }, + { + "name": "union", + "raw": "keyof SurfaceVisualProps | 'elevation'", + "elements": [ + { + "name": "unknown" + }, + { + "name": "literal", + "value": "'elevation'" + } + ] + } + ], + "raw": "Omit" } ], - "raw": "StyleProp" + "raw": "AnimatedStyle<\n Omit\n>" } ], - "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 +11316,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 +11343,6 @@ ] }, "description": "" - }, - "container": { - "required": false, - "tsType": { - "name": "boolean" - }, - "description": "@internal" } } }, @@ -11780,19 +11864,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/ButtonExample.tsx b/example/src/Examples/ButtonExample.tsx index e250644756..2a164e8e77 100644 --- a/example/src/Examples/ButtonExample.tsx +++ b/example/src/Examples/ButtonExample.tsx @@ -283,18 +283,22 @@ const ButtonExample = () => { - @@ -380,17 +384,7 @@ const styles = StyleSheet.create({ width: '100%', marginTop: 10, }, - customRadius: { - borderTopLeftRadius: 16, - borderTopRightRadius: 0, - borderBottomLeftRadius: 0, - borderBottomRightRadius: 16, - }, - noRadius: { - borderRadius: 0, - }, - customRadiusAndPadding: { - borderRadius: 4, + customPadding: { paddingHorizontal: 12, paddingVertical: 6, }, diff --git a/example/src/Examples/CardExample.tsx b/example/src/Examples/CardExample.tsx index 61c7ebe822..1061d4f904 100644 --- a/example/src/Examples/CardExample.tsx +++ b/example/src/Examples/CardExample.tsx @@ -104,17 +104,24 @@ const CardExample = () => { /> - - { accessibilityIgnoresInvertColors /> } - style={[styles.chip, styles.customBorderRadius]} + style={styles.chip} + theme={{ shapes: { corner: { small: 16 } } }} > Compact with custom border radius @@ -258,7 +259,8 @@ const ChipExample = () => { accessibilityIgnoresInvertColors /> } - style={[styles.chip, styles.customBorderRadius]} + style={styles.chip} + theme={{ shapes: { corner: { small: 16 } } }} > Compact with custom border radius @@ -275,12 +277,15 @@ const ChipExample = () => { {}} - style={[ - styles.chip, - { - backgroundColor: color(customColor).alpha(0.2).rgb().string(), + style={styles.chip} + theme={{ + colors: { + secondaryContainer: color(customColor) + .alpha(0.2) + .rgb() + .string(), }, - ]} + }} selectedColor={customColor} > Flat selected chip with custom color @@ -296,12 +301,12 @@ const ChipExample = () => { selected mode="outlined" onPress={() => {}} - style={[ - styles.chip, - { - backgroundColor: color(customColor).alpha(0.2).rgb().string(), + style={styles.chip} + theme={{ + colors: { + surface: color(customColor).alpha(0.2).rgb().string(), }, - ]} + }} selectedColor={customColor} > Outlined selected chip with custom color @@ -403,9 +408,6 @@ const styles = StyleSheet.create({ marginVertical: 4, marginHorizontal: 12, }, - customBorderRadius: { - borderRadius: 16, - }, }); export default ChipExample; diff --git a/example/src/Examples/Dialogs/DialogWithCustomColors.tsx b/example/src/Examples/Dialogs/DialogWithCustomColors.tsx index f3625b835e..75b5ee6b6e 100644 --- a/example/src/Examples/Dialogs/DialogWithCustomColors.tsx +++ b/example/src/Examples/Dialogs/DialogWithCustomColors.tsx @@ -13,8 +13,10 @@ const DialogWithCustomColors = ({ diff --git a/example/src/Examples/SurfaceExample.tsx b/example/src/Examples/SurfaceExample.tsx index fe420830ce..e941c7c843 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,8 @@ const styles = StyleSheet.create({ }, verticalSurface: { height: '48%', + }, + verticalSurfaceContent: { justifyContent: 'center', }, diff --git a/jest/reanimatedSnapshotSerializer.js b/jest/reanimatedSnapshotSerializer.js new file mode 100644 index 0000000000..0d20dec218 --- /dev/null +++ b/jest/reanimatedSnapshotSerializer.js @@ -0,0 +1,27 @@ +const reanimatedTestProps = new Set([ + 'jestAnimatedProps', + 'jestAnimatedStyle', + 'jestInlineStyle', +]); + +module.exports = { + test(value) { + return ( + value !== null && + typeof value === 'object' && + value.props !== null && + typeof value.props === 'object' && + Object.keys(value.props).some((prop) => reanimatedTestProps.has(prop)) + ); + }, + print(value, serialize) { + return serialize({ + ...value, + props: Object.fromEntries( + Object.entries(value.props).filter( + ([prop]) => !reanimatedTestProps.has(prop) + ) + ), + }); + }, +}; 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/package.json b/package.json index 25ca91fb73..6db11f9fef 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,9 @@ "/jest/jestSetupAfterEnv.js", "@testing-library/react-native" ], + "snapshotSerializers": [ + "/jest/reanimatedSnapshotSerializer.js" + ], "cacheDirectory": "./cache/jest", "testPathIgnorePatterns": [ "\\.d\\.ts$" diff --git a/src/components/Appbar/Appbar.tsx b/src/components/Appbar/Appbar.tsx index 8ef9965819..7e62fd4df9 100644 --- a/src/components/Appbar/Appbar.tsx +++ b/src/components/Appbar/Appbar.tsx @@ -1,19 +1,24 @@ 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 AppbarStyle = Omit; + 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 +56,7 @@ export type Props = Omit, 'style'> & { * @optional */ theme?: ThemeProp; - style?: Animated.WithAnimatedValue>; + style?: StyleProp; }; /** @@ -145,30 +150,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 borderStyles = getAppbarBorders(restStyle); + const isMode = (modeToCompare: AppbarModes) => { return mode === modeToCompare; }; @@ -210,24 +211,25 @@ const Appbar = ({ const insets = { paddingBottom: safeAreaInsets?.bottom, paddingTop: safeAreaInsets?.top, - paddingLeft: safeAreaInsets?.left, - paddingRight: safeAreaInsets?.right, + paddingLeft: (safeAreaInsets?.left ?? 0) + APPBAR_HORIZONTAL_PADDING, + paddingRight: (safeAreaInsets?.right ?? 0) + APPBAR_HORIZONTAL_PADDING, }; return ( {shouldAddLeftSpacing ? : null} {(isMode('small') || isMode('center-aligned')) && ( @@ -308,7 +310,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..1c486f9ccd 100644 --- a/src/components/Appbar/AppbarHeader.tsx +++ b/src/components/Appbar/AppbarHeader.tsx @@ -1,22 +1,18 @@ import * as React from 'react'; -import { Animated, Platform, StyleSheet, View } from 'react-native'; -import type { ColorValue, StyleProp, ViewStyle } from 'react-native'; +import { Platform, StyleSheet } from 'react-native'; +import type { ColorValue, StyleProp } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Appbar } from './Appbar'; -import { - getAppbarBackgroundColor, - modeAppbarHeight, - getAppbarBorders, -} from './utils'; +import type { AppbarStyle } from './Appbar'; +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 +48,7 @@ export type Props = Omit< * @optional */ theme?: ThemeProp; - style?: Animated.WithAnimatedValue>; + style?: StyleProp; }; /** @@ -100,64 +96,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; + height?: AppbarStyle['height']; 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); + const headerHeight = typeof height === 'number' ? height + topInset : height; 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..29978636b9 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'; @@ -15,17 +15,25 @@ export type AppbarChildProps = { const borderStyleProperties = [ 'borderRadius', + 'borderBottomEndRadius', + 'borderBottomStartRadius', + 'borderEndEndRadius', + 'borderEndStartRadius', + 'borderStartEndRadius', + 'borderStartStartRadius', + 'borderTopEndRadius', + 'borderTopStartRadius', 'borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomRightRadius', 'borderBottomLeftRadius', + 'borderCurve', ] satisfies readonly (keyof ViewStyle)[]; export const getAppbarBackgroundColor = ( theme: InternalTheme, - _elevation: number, - customBackground?: ColorValue, - elevated?: boolean + elevated: boolean, + customBackground?: ColorValue ) => { const { colors } = theme; if (customBackground) { @@ -54,19 +62,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..dddcf330c0 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -1,21 +1,37 @@ 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, +} from 'react-native-reanimated'; +import { scheduleOnRN } from 'react-native-worklets'; import useLatestCallback from 'use-latest-callback'; import Button from './Button/Button'; import Icon from './Icon'; import type { IconSource } from './Icon'; import Surface from './Surface'; +import type { SurfaceStyle } from './Surface'; import Text from './Typography/Text'; import { useInternalTheme } from '../core/theming'; -import type { $Omit, $RemoveChildren, ThemeProp } from '../types'; +import type { $RemoveChildren, Elevation, 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?: Elevation; /** * 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,9 +461,14 @@ const BottomNavigationBar = ({ bottom: safeAreaInsets?.bottom ?? bottom, }; + const pointerEvents = layout.measured + ? keyboardHidesNavigationBar && keyboardVisible + ? 'none' + : 'auto' + : 'none'; + return ( - ({ } : null, style, + { pointerEvents }, ]} - pointerEvents={ - layout.measured - ? keyboardHidesNavigationBar && keyboardVisible - ? 'none' - : 'auto' - : 'none' - } onLayout={onLayout} - container > - - + - {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..00f0242f0f 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,24 +7,25 @@ import type { Role, StyleProp, TextStyle, + ViewProps, ViewStyle, } from 'react-native'; 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'; import Icon from '../Icon'; import type { IconSource } from '../Icon'; import Surface from '../Surface'; +import type { SurfaceStyle } from '../Surface'; 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 +123,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 +193,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,52 +214,33 @@ 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(); + + if (isElevationEntitled) { + 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(); + + if (isElevationEntitled) { + setPressed(false); } }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const flattenedStyles = (StyleSheet.flatten(style) || {}) as ViewStyle; - const [, borderRadiusStyles] = splitStyles( - flattenedStyles, - (style) => style.startsWith('border') && style.endsWith('Radius') - ); + const elevationTransitionDuration = + theme.motion.duration[pressed ? 'short4' : 'short3'] * + theme.animation.scale; const borderRadius = theme.shapes.corner.largeIncreased; const iconSize = 18; @@ -279,17 +261,7 @@ const Button = ({ dark, }); - const touchableStyle = { - ...borderRadiusStyles, - borderRadius: borderRadiusStyles.borderRadius ?? borderRadius, - }; - - const buttonStyle = { - backgroundColor: backgroundOpacity < 1 ? 'transparent' : backgroundColor, - borderColor, - borderWidth, - ...touchableStyle, - }; + const touchableStyle = { borderRadius }; const { color: customLabelColor, fontSize: customLabelSize } = StyleSheet.flatten(labelStyle) || {}; @@ -321,9 +293,19 @@ const Button = ({ {...rest} ref={ref} testID={`${testID}-container`} - style={[styles.button, compact && styles.compact, buttonStyle, style]} + backgroundColor={backgroundOpacity < 1 ? 'transparent' : backgroundColor} + {...touchableStyle} + style={[ + styles.button, + compact && styles.compact, + { + borderColor, + borderWidth, + }, + style, + ]} elevation={elevation} - container + transitionDuration={elevationTransitionDuration} > {backgroundOpacity < 1 && ( , 'mode'> & { +export type Props = Omit & { /** * Mode of the Card. * - `elevated` - Card with elevation. @@ -73,12 +76,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?: Elevation; /** * Style of card's inner content. */ contentStyle?: StyleProp; - style?: Animated.WithAnimatedValue>; + style?: StyleProp; /** * @optional */ @@ -91,6 +94,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 +148,7 @@ const Card = ({ ...rest }: (OutlinedCardProps | ElevatedCardProps | ContainedCardProps) & Props) => { const theme = useInternalTheme(themeOverrides); + const isMode = React.useCallback( (modeToCompare: Mode) => { return cardMode === modeToCompare; @@ -155,34 +163,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); @@ -204,15 +201,7 @@ const Card = ({ const { borderColor = themedBorderColor } = flattenedStyles; - const [, borderRadiusStyles] = splitStyles( - flattenedStyles, - (style) => style.startsWith('border') && style.endsWith('Radius') - ); - - const borderRadiusCombinedStyles = { - borderRadius: theme.shapes.corner.medium, - ...borderRadiusStyles, - }; + const borderRadius = theme.shapes.corner.medium; const content = ( @@ -222,7 +211,6 @@ const Card = ({ index, total, siblings, - borderRadiusStyles, }) : child )} @@ -232,15 +220,12 @@ const Card = ({ return ( {isMode('outlined') && ( @@ -252,7 +237,7 @@ const Card = ({ borderColor, }, styles.outline, - borderRadiusCombinedStyles, + { borderRadius }, ]} /> )} diff --git a/src/components/Chip/Chip.tsx b/src/components/Chip/Chip.tsx index b6482e9209..3bbbe32cf5 100644 --- a/src/components/Chip/Chip.tsx +++ b/src/components/Chip/Chip.tsx @@ -1,12 +1,12 @@ 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, - ViewStyle, + ViewProps, } from 'react-native'; import useLatestCallback from 'use-latest-callback'; @@ -15,17 +15,18 @@ 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'; import MaterialCommunityIcon from '../MaterialCommunityIcon'; import Surface from '../Surface'; +import type { SurfaceStyle } from '../Surface'; 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 +124,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 +145,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 +206,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,36 +220,24 @@ 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 elevationTransitionDuration = + theme.motion.duration[pressed ? 'short4' : 'short3'] * + theme.animation.scale; + const opacity = 0.38; const defaultBorderRadius = theme.shapes.corner.small; const iconSize = 18; - const { - backgroundColor: customBackgroundColor, - borderRadius = defaultBorderRadius, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - } = (StyleSheet.flatten(style) || {}) as ViewStyle; + const borderRadius = defaultBorderRadius; const { borderColor, @@ -259,12 +250,11 @@ const Chip = ({ isOutlined, theme, selectedColor, - customBackgroundColor, disabled, }); - const elevationStyle = elevation; const multiplier = compact ? 1.5 : 2; + const labelSpacings = { marginRight: onClose ? 0 : 8 * multiplier, marginLeft: @@ -272,30 +262,26 @@ const Chip = ({ ? 4 * multiplier : 8 * multiplier, }; + const contentSpacings = { paddingRight: onClose ? 34 : 0, }; + const labelTextStyle = { color: textColor, ...theme.fonts.labelLarge, }; + return ( { +}: BaseProps & { selectedColor?: ColorValue }) => { const isSelectedColor = selectedColor !== undefined; const { colors } = md3(theme); @@ -85,19 +85,8 @@ const getDefaultBackgroundColor = ({ return colors.secondaryContainer; }; -const getBackgroundColor = ({ - theme, - isOutlined, - disabled, - customBackgroundColor, -}: BaseProps & { - customBackgroundColor?: ColorValue; -}) => { +const getBackgroundColor = ({ theme, isOutlined, disabled }: BaseProps) => { const { colors } = md3(theme); - if (typeof customBackgroundColor === 'string') { - return customBackgroundColor; - } - if (disabled) { if (isOutlined) { return 'transparent'; @@ -108,22 +97,6 @@ const getBackgroundColor = ({ return getDefaultBackgroundColor({ theme, isOutlined }); }; -const getSelectedBackgroundColor = ({ - theme, - isOutlined, - disabled, - customBackgroundColor, -}: BaseProps & { - customBackgroundColor?: ColorValue; -}) => { - return getBackgroundColor({ - theme, - disabled, - isOutlined, - customBackgroundColor, - }); -}; - const getIconColor = ({ theme, isOutlined, @@ -153,10 +126,8 @@ export const getChipColors = ({ isOutlined, theme, selectedColor, - customBackgroundColor, disabled, }: BaseProps & { - customBackgroundColor?: ColorValue; disabled?: boolean; selectedColor?: ColorValue; }) => { @@ -164,12 +135,6 @@ export const getChipColors = ({ const backgroundColor = getBackgroundColor({ ...baseChipColorProps, - customBackgroundColor, - }); - - const selectedBackgroundColor = getSelectedBackgroundColor({ - ...baseChipColorProps, - customBackgroundColor, }); const contentOpacity = disabled @@ -180,7 +145,6 @@ export const getChipColors = ({ borderColor: getBorderColor({ ...baseChipColorProps, selectedColor, - backgroundColor, }), textColor: getTextColor({ ...baseChipColorProps, @@ -192,6 +156,6 @@ export const getChipColors = ({ }), contentOpacity, backgroundColor, - selectedBackgroundColor, + selectedBackgroundColor: backgroundColor, }; }; diff --git a/src/components/Dialog/Dialog.tsx b/src/components/Dialog/Dialog.tsx index a7b082fe89..89832fe658 100644 --- a/src/components/Dialog/Dialog.tsx +++ b/src/components/Dialog/Dialog.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import { Animated, Platform, StyleSheet } from 'react-native'; -import type { StyleProp, ViewStyle } from 'react-native'; +import { Platform, StyleSheet } from 'react-native'; +import type { StyleProp } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -10,8 +10,9 @@ import DialogIcon from './DialogIcon'; import DialogScrollArea from './DialogScrollArea'; import DialogTitle from './DialogTitle'; import { useInternalTheme } from '../../core/theming'; -import type { ThemeProp } from '../../types'; +import type { Elevation, ThemeProp } from '../../types'; import Modal from '../Modal'; +import type { SurfaceStyle } from '../Surface'; import type { DialogChildProps } from './utils'; export type Props = { @@ -35,7 +36,7 @@ export type Props = { * Content of the `Dialog`. */ children: React.ReactNode; - style?: Animated.WithAnimatedValue>; + 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..63fb0faf1f 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,16 @@ import type { ViewStyle, } from 'react-native'; +import Animated, { + Easing, + ReduceMotion, + useAnimatedStyle, + useSharedValue, + withTiming, +} 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'; @@ -30,6 +37,7 @@ import { addEventListener } from '../../utils/addEventListener'; import { BackHandler } from '../../utils/BackHandler/BackHandler'; import Portal from '../Portal/Portal'; import Surface from '../Surface'; +import type { SurfaceStyle } from '../Surface'; export type Props = { /** @@ -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,10 @@ 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,13 +216,15 @@ 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); const menuRef = React.useRef(null); - const prevRendered = React.useRef(false); + const isShownRef = React.useRef(false); const keyboardDidShow = React.useCallback((e: RNKeyboardEvent) => { const keyboardHeight = e.endCoordinates.height; @@ -258,7 +267,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 +313,26 @@ const Menu = ({ [anchor] ); + const handleShowAnimationFinished = useLatestCallback((finished: boolean) => { + if (!finished || !prevVisible.current) { + return; + } + + isShownRef.current = true; + focusFirstDOMNode(menuRef.current); + }); + + const handleHideAnimationFinished = useLatestCallback((finished: boolean) => { + if (!finished || prevVisible.current) { + return; + } + + setMenuLayout({ width: 0, height: 0 }); + setRendered(false); + isShownRef.current = false; + focusFirstDOMNode(anchorRef.current); + }); + const show = React.useCallback(async () => { const windowLayoutResult = Dimensions.get('window'); const [menuLayoutResult, anchorLayoutResult] = await Promise.all([ @@ -308,6 +340,10 @@ const Menu = ({ measureAnchorLayout(), ]); + if (!prevVisible.current) { + return; + } + // When visible is true for first render // native views can be still not rendered and // measureMenuLayout/measureAnchorLayout functions @@ -344,45 +380,54 @@ const Menu = ({ }); attachListeners(); + requestAnimationFrame(() => { + if (!prevVisible.current) { + return; + } + 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(); + isShownRef.current = false; 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) => { @@ -390,7 +435,7 @@ const Menu = ({ // We need to do the same here so that the ref is up-to-date await Promise.resolve(); - if (display && !prevRendered.current) { + if (display && !isShownRef.current) { await show(); return; } @@ -403,8 +448,6 @@ const Menu = ({ ); React.useEffect(() => { - const opacityAnimation = opacityAnimationRef.current; - const scaleAnimation = scaleAnimationRef.current; keyboardDidShowListenerRef.current = Keyboard.addListener( 'keyboardDidShow', keyboardDidShow @@ -418,26 +461,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 +493,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 +504,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 +592,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 +621,38 @@ const Menu = ({ } } - const shadowMenuContainerStyle = { - opacity: opacityAnimationRef.current, + const shadowMenuContainerStyle: ViewStyle = 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 +695,36 @@ const Menu = ({ > - {(scrollableMenuHeight && ( - - {children} - - )) || {children}} + + {(scrollableMenuHeight && ( + + {children} + + )) || {children}} + @@ -703,8 +742,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..22a3f7ba8e 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, SurfaceStyle } 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,25 @@ 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; + /** + * 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 +97,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 +130,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 +189,33 @@ function Modal({ 'hardwareBackPress', onHardwareBackPress ); + return () => subscription.remove(); }, [dismissable, dismissableBackButton, onDismissCallback, visible]); - if (!visibleInternal) { + const transitionTimingFunction = cubicBezier(1 / 3, 1, 2 / 3, 1); + + const backdropTransitionStyle: AnimatedStyle = { + transitionDuration: scale * DEFAULT_DURATION, + transitionProperty: 'opacity', + transitionTimingFunction, + }; + + const contentTransitionStyle: AnimatedStyle = { + transitionProperty: 'opacity', + transitionTimingFunction, + }; + + 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 +234,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, backdropTransitionStyle]} testID={`${testID}-backdrop`} /> {children} @@ -238,9 +277,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..8d7e038c31 100644 --- a/src/components/Searchbar.tsx +++ b/src/components/Searchbar.tsx @@ -1,12 +1,11 @@ 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, StyleProp, TextInputProps, TextStyle, - ViewStyle, } from 'react-native'; import ActivityIndicator from './ActivityIndicator'; @@ -15,10 +14,11 @@ import type { IconSource } from './Icon'; import IconButton from './IconButton/IconButton'; import MaterialCommunityIcon from './MaterialCommunityIcon'; import Surface from './Surface'; +import type { SurfaceStyle } from './Surface'; import { useLocale } from '../core/locale'; import { useInternalTheme } from '../core/theming'; import { cornerNone } from '../theme/tokens/sys/shape'; -import type { ThemeProp } from '../types'; +import type { Elevation, ThemeProp } from '../types'; interface Style { marginRight: number; @@ -111,12 +111,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?: Elevation; /** * 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 +188,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 +232,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?: Elevation; /** * 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 ? ( @@ -385,8 +373,8 @@ const styles = StyleSheet.create({ flexDirection: 'row', justifyContent: 'space-between', margin: 8, - borderRadius: 4, minHeight: 48, + pointerEvents: 'box-none', }, content: { marginHorizontal: 16, diff --git a/src/components/Surface.tsx b/src/components/Surface.tsx index b0b0f0c4aa..fa84f49051 100644 --- a/src/components/Surface.tsx +++ b/src/components/Surface.tsx @@ -1,205 +1,147 @@ 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 { ColorValue, StyleProp, ViewProps, ViewStyle } from 'react-native'; + +import Animated, { + cubicBezier, + 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?: ColorValue; + /** + * 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; /** - * Content of the `Surface`. + * Radius of the bottom-right corner of the Surface. */ - children: React.ReactNode; - style?: Animated.WithAnimatedValue>; + borderBottomRightRadius?: 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 bottom-start corner of the Surface. */ - elevation?: SurfaceElevation; + borderBottomStartRadius?: 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 end-end corner of the Surface. */ - mode?: 'flat' | 'elevated'; + borderEndEndRadius?: BorderRadius; /** - * @optional + * Radius of the end-start corner of the Surface. */ - theme?: ThemeProp; + borderEndStartRadius?: BorderRadius; /** - * TestID used for testing purposes + * Radius of the start-end corner of the Surface. */ - testID?: string; - ref?: React.Ref; + borderStartEndRadius?: BorderRadius; /** - * @internal + * Radius of the start-start corner of the Surface. */ - container?: boolean; + borderStartStartRadius?: BorderRadius; + /** + * Radius of the top-end corner of the Surface. + */ + borderTopEndRadius?: BorderRadius; + /** + * Radius of the top-left corner of the Surface. + */ + borderTopLeftRadius?: BorderRadius; + /** + * Radius of the top-right corner of the Surface. + */ + borderTopRightRadius?: BorderRadius; + /** + * Radius of the top-start corner of the Surface. + */ + borderTopStartRadius?: BorderRadius; + /** + * Corner curve of the Surface on iOS. + */ + borderCurve?: ViewStyle['borderCurve']; }; -const outerLayerStyleProperties: (keyof ViewStyle)[] = [ - 'position', - 'alignSelf', - 'top', - 'right', - 'bottom', - 'left', - 'start', - 'end', - 'flex', - 'flexShrink', - 'flexGrow', - 'width', - 'height', - 'transform', - 'opacity', -]; +export type SurfaceStyle = AnimatedStyle< + Omit +>; -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 & { + /** + * Duration of the background, elevation, and shadow transitions in + * milliseconds. + */ + transitionDuration?: number; + /** + * 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 +150,7 @@ const SurfaceIOS = ({ * import { StyleSheet } from 'react-native'; * * const MyComponent = () => ( - * + * * Surface * * ); @@ -217,9 +159,9 @@ const SurfaceIOS = ({ * * const styles = StyleSheet.create({ * surface: { - * padding: 8, * height: 80, * width: 80, + * padding: 8, * alignItems: 'center', * justifyContent: 'center', * }, @@ -231,44 +173,92 @@ const Surface = ({ children, theme: overridenTheme, style, - testID = 'surface', + backgroundColor: customBackgroundColor, + borderRadius, + borderBottomEndRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderBottomStartRadius, + borderEndEndRadius, + borderEndStartRadius, + borderStartEndRadius, + borderStartStartRadius, + borderTopEndRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderTopStartRadius, + borderCurve = 'continuous', + testID, mode = 'elevated', + transitionDuration: customTransitionDuration, 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 backgroundStyle = { backgroundColor }; + + const shapeProps = { + borderRadius, + borderBottomEndRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderBottomStartRadius, + borderEndEndRadius, + borderEndStartRadius, + borderStartEndRadius, + borderStartStartRadius, + borderTopEndRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderTopStartRadius, + borderCurve, + }; + + // Reanimated styles can't be shared between different views + // So we need to create two separate styles for the surface and shadow layers + const visualStyle = useSurfaceVisualStyle(shapeProps); + const shadowVisualStyle = useSurfaceVisualStyle(shapeProps); const isElevated = mode === 'elevated'; + const transitionDuration = + customTransitionDuration ?? + theme.motion.duration.short3 * theme.animation.scale; + const transitionDurationStyle: AnimatedStyle = { + transitionDuration, + }; + const transitionTimingFunction = cubicBezier(...theme.motion.easing.standard); + const transitionProperty = + // FIXME: Reanimated can't animate PlatformColor and DynamicColorIOS + typeof backgroundColor === 'string' ? ['backgroundColor' as const] : []; + if (Platform.OS === 'web') { - const { pointerEvents = 'auto' } = props; + const [elevationShadow] = shadow(elevation, theme.colors.shadow); + + const transitionStyle: AnimatedStyle = { + transitionTimingFunction, + transitionProperty: [...transitionProperty, 'boxShadow'], + }; + return ( {children} @@ -277,40 +267,25 @@ 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]; + const transitionStyle: AnimatedStyle = { + transitionTimingFunction, + transitionProperty: [...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 +293,97 @@ const Surface = ({ ); } + const [spotShadow, ambientShadow] = shadow(elevation, theme.colors.shadow); + + const transitionStyle: AnimatedStyle = { + transitionTimingFunction, + transitionProperty: [ + ...transitionProperty, + 'shadowOpacity', + 'shadowOffset', + 'shadowRadius', + ], + }; + return ( - + {isElevated ? ( + + ) : null} {children} - + ); }; +const useSurfaceVisualStyle = ({ + borderRadius, + borderBottomEndRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderBottomStartRadius, + borderEndEndRadius, + borderEndStartRadius, + borderStartEndRadius, + borderStartStartRadius, + borderTopEndRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderTopStartRadius, + borderCurve, +}: Omit) => + useAnimatedStyle(() => + Object.fromEntries( + Object.entries({ + borderRadius, + borderBottomEndRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderBottomStartRadius, + borderEndEndRadius, + borderEndStartRadius, + borderStartEndRadius, + borderStartStartRadius, + borderTopEndRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderTopStartRadius, + borderCurve, + }).map(([property, value]) => [ + property, + isSharedValue(value) ? value.value : value, + ]) + ) + ); + +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..5bba9563f4 100644 --- a/src/components/__tests__/Appbar/Appbar.test.tsx +++ b/src/components/__tests__/Appbar/Appbar.test.tsx @@ -1,7 +1,4 @@ -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'; @@ -280,176 +277,53 @@ 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( + tokens.md.ref.palette.neutral94 ); }); - 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( + tokens.md.ref.palette.neutral12 ); }); }); -describe('animated value changes correctly', () => { - it('appbar animated value changes correctly', async () => { - const value = new Animated.Value(1); - await render( - - - +describe('getAppbarBorders', () => { + const borderStyles = { + borderRadius: 1, + borderBottomEndRadius: 2, + borderBottomStartRadius: 3, + borderEndEndRadius: 4, + borderEndStartRadius: 5, + borderStartEndRadius: 6, + borderStartStartRadius: 7, + borderTopEndRadius: 8, + borderTopStartRadius: 9, + borderTopLeftRadius: 10, + borderTopRightRadius: 11, + borderBottomRightRadius: 12, + borderBottomLeftRadius: 13, + borderCurve: 'continuous' as const, + }; + + it('returns every border style and excludes unrelated styles', () => { + expect(getAppbarBorders({ ...borderStyles, height: 60, top: 13 })).toEqual( + borderStyles ); - 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 }], - }); - }); - - it('action animated value changes correctly', async () => { - const value = new Animated.Value(1); - 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 }], - }); }); - it('back action animated value changes correctly', async () => { - const value = new Animated.Value(1); - 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 }], - }); - }); - - it('header animated value changes correctly', async () => { - const value = new Animated.Value(1); - 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 }], - }); - }); - - it('header bottom border radius applied correctly', async () => { - const style = { borderBottomLeftRadius: 16, borderBottomRightRadius: 16 }; - - await render( - - - {null} - - - ); - expect(screen.getByTestId('appbar-header-root-layer')).toHaveStyle(style); - }); - - describe('getAppbarBorders', () => { - const style = { borderRadius: 10, height: 60, top: 13 }; - - it('should return only border radius styles', () => { - expect(getAppbarBorders(style)).toEqual({ borderRadius: 10 }); - }); - - it('should return empty object if no borders are passed', () => { - const style = { height: 60, top: 13 }; - expect(getAppbarBorders(style)).toEqual({}); - }); + it('returns an empty object when no border styles are passed', () => { + expect(getAppbarBorders({ height: 60, top: 13 })).toEqual({}); }); }); diff --git a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap index a5d9d95766..30aaf4e66d 100644 --- a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap +++ b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap @@ -4,33 +4,43 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` - + - + - + + + - - - - - magnify - - - - + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + magnify + - + + + + + - - - - - close - - - - + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + close + @@ -420,33 +488,43 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A - - + + + - - + + - - - - - + } + /> - + + - - 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..e4a275921f 100644 --- a/src/components/__tests__/BottomNavigation.test.tsx +++ b/src/components/__tests__/BottomNavigation.test.tsx @@ -1,4 +1,12 @@ -import { Animated, Easing, Platform, StyleSheet, Text } from 'react-native'; +import { + Animated, + Easing, + Keyboard, + Platform, + StyleSheet, + Text, +} from 'react-native'; +import type { KeyboardEvent } from 'react-native'; import { describe, expect, it, jest } from '@jest/globals'; import { act, fireEvent, userEvent } from '@testing-library/react-native'; @@ -431,6 +439,60 @@ it('renders custom background color passed to barStyle property', async () => { expect(wrapper).toHaveStyle({ backgroundColor: Palette.error60 }); }); +it('uses the rendered bar height when hiding it for the keyboard', async () => { + let handleKeyboardShow: ((event: KeyboardEvent) => void) | undefined; + const addKeyboardListener = Keyboard.addListener.bind(Keyboard); + const keyboardListenerSpy = jest + .spyOn(Keyboard, 'addListener') + .mockImplementation((event, listener) => { + if (event.endsWith('Show')) { + handleKeyboardShow = listener; + } + + return addKeyboardListener(event, listener); + }); + + await render( + + ); + + const navigation = screen.getByTestId('bottom-navigation'); + + await fireEvent(navigation, 'layout', { + nativeEvent: { + layout: { height: 72, width: 360 }, + }, + }); + + await act(() => { + handleKeyboardShow?.({ + duration: 0, + easing: 'keyboard', + endCoordinates: { + screenX: 0, + screenY: 500, + width: 360, + height: 300, + }, + }); + jest.runAllTimers(); + }); + + expect(navigation).toHaveStyle({ + height: 96, + position: 'absolute', + transform: [{ translateY: 72 }], + }); + + keyboardListenerSpy.mockRestore(); +}); + it('renders a single tab', async () => { await render( { ); }); -it('barStyle animated value changes correctly', async () => { +it('supports animated styles in bar', async () => { const value = new Animated.Value(1); await render( - {}} - renderScene={renderScene} - testID={'bottom-navigation'} - barStyle={[{ transform: [{ scale: value }] }]} + onTabPress={jest.fn()} + testID="bottom-navigation" + style={[{ transform: [{ scale: value }] }]} /> ); - expect(screen.getByTestId('bottom-navigation-bar-outer-layer')).toHaveStyle({ + + expect(screen.getByTestId('bottom-navigation')).toHaveStyle({ transform: [{ scale: 1 }], }); @@ -614,7 +676,8 @@ 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')).toHaveStyle({ transform: [{ scale: 1.5 }], }); }); diff --git a/src/components/__tests__/Button.test.tsx b/src/components/__tests__/Button.test.tsx index 4da466837a..be3d573d29 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'; @@ -16,15 +16,6 @@ const styles = StyleSheet.create({ flexing: { flexDirection: 'row-reverse', }, - customRadius: { - borderTopLeftRadius: 16, - borderTopRightRadius: 0, - borderBottomLeftRadius: 0, - borderBottomRightRadius: 16, - }, - noRadius: { - borderRadius: 0, - }, }); it('renders text button by default', async () => { @@ -155,54 +146,6 @@ it('renders button with an accessibility hint', async () => { expect(tree).toMatchSnapshot(); }); -it('renders button with custom border radius', async () => { - await render( - - ); - - expect(screen.getByTestId('custom-radius-container')).toHaveStyle( - styles.customRadius - ); - expect(screen.getByTestId('custom-radius')).toHaveStyle(styles.customRadius); -}); - -it('renders outlined button with custom border radius', async () => { - await render( - - ); - - expect(screen.getByTestId('custom-radius-container')).toHaveStyle( - styles.customRadius - ); - expect(screen.getByTestId('custom-radius')).toHaveStyle({ - borderTopLeftRadius: 15, // styles.customRadius - 1px outline - borderTopRightRadius: 0, - borderBottomLeftRadius: 0, - borderBottomRightRadius: 15, // styles.customRadius - 1px outline - }); -}); - -it('renders button without border radius', async () => { - await render( - - ); - - expect(screen.getByTestId('custom-radius-container')).toHaveStyle( - styles.noRadius - ); - expect(screen.getByTestId('custom-radius')).toHaveStyle(styles.noRadius); -}); - it('should execute onPressIn', async () => { const onPressInMock = jest.fn(); const onPress = jest.fn(); @@ -709,33 +652,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..74637a8a53 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 { Platform, StyleSheet, Text } from 'react-native'; -import { describe, expect, it, jest } from '@jest/globals'; -import { act } from '@testing-library/react-native'; +import { afterEach, describe, expect, it, jest } from '@jest/globals'; import { getTheme } from '../../../core/theming'; import { render, screen } from '../../../test-utils'; @@ -11,9 +10,6 @@ import Card from '../../Card/Card'; import { getCardColors, getCardCoverStyle } from '../../Card/utils'; const styles = StyleSheet.create({ - customBorderRadius: { - borderRadius: 32, - }, customCoverRadius: { borderTopLeftRadius: 4, borderTopRightRadius: 8, @@ -25,6 +21,10 @@ const styles = StyleSheet.create({ }, }); +afterEach(() => { + jest.restoreAllMocks(); +}); + describe('Card', () => { it('renders an outlined card', async () => { const tree = (await render({null})).toJSON(); @@ -32,40 +32,49 @@ describe('Card', () => { expect(tree).toMatchSnapshot(); }); - it('renders an outlined card with custom border radius and color', async () => { + it('renders an outlined card with a custom outline color', async () => { + const testID = 'custom-outline-card'; + await render( {null} ); - expect(screen.getByTestId('card-outline')).toHaveStyle({ - borderRadius: 32, + expect(screen.getByTestId(`${testID}-outline`)).toHaveStyle({ borderColor: 'purple', + borderWidth: 1, }); }); it('renders an outlined card with custom border color', async () => { + const testID = 'custom-border-card'; + await render( {null} ); - expect(screen.getByLabelText('card')).toHaveStyle({ + expect(screen.getByTestId(`${testID}-outline`)).toHaveStyle({ borderColor: Palette.error50, + borderWidth: 1, }); }); - it('renders with a custom theme', async () => { + it('renders with a custom theme background color', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); + await render( { screen.getByTestId('card-actions').props.children[0].props.mode ).toBe('contained'); }); - - it('renders button with custom styles', async () => { - await render( - - - - - - ); - - expect(screen.getByTestId('card-actions-button')).toHaveStyle({ - borderRadius: 32, - }); - }); }); describe('getCardColors - background color', () => { @@ -224,32 +213,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..5c39f6bf05 100644 --- a/src/components/__tests__/Card/__snapshots__/Card.test.tsx.snap +++ b/src/components/__tests__/Card/__snapshots__/Card.test.tsx.snap @@ -4,27 +4,33 @@ exports[`Card renders an outlined card 1`] = ` - - - + - + "shadowOpacity": 0, + "shadowRadius": 0, + }, + ] + } + /> + + `; diff --git a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap index 54f2e4f7a4..714b65414e 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap @@ -83,6 +83,7 @@ exports[`renders Checkbox with custom testID 1`] = ` } > { }); }); -describe('getChipColor - selected background color', () => { - it('should return custom color, outlined mode', () => { - expect( - getChipColors({ - theme: getTheme(), - customBackgroundColor: 'purple', - isOutlined: true, - }) - ).toMatchObject({ - selectedBackgroundColor: 'purple', - }); - }); - - it('should return custom color, flat mode', () => { - expect( - getChipColors({ - theme: getTheme(), - customBackgroundColor: 'purple', - isOutlined: false, - }) - ).toMatchObject({ - selectedBackgroundColor: 'purple', - }); - }); - - it('should return theme color, for theme version 3, flat mode', () => { - expect( - getChipColors({ - theme: getTheme(), - isOutlined: false, - }) - ).toMatchObject({ - selectedBackgroundColor: getTheme().colors.secondaryContainer, - }); - }); -}); - describe('getChipColor - background color', () => { - it('should return custom color', () => { - expect( - getChipColors({ - theme: getTheme(), - customBackgroundColor: 'purple', - isOutlined: false, - }) - ).toMatchObject({ - backgroundColor: 'purple', - }); - }); - it('should return theme color, for theme version 3, outlined mode', () => { expect( getChipColors({ @@ -317,18 +265,6 @@ describe('getChipColor - border color', () => { }); }); - it('should return custom color, flat mode', () => { - expect( - getChipColors({ - theme: getTheme(true), - customBackgroundColor: 'purple', - isOutlined: false, - }) - ).toMatchObject({ - borderColor: 'transparent', - }); - }); - it('should return theme color, light mode, outlined mode', () => { expect( getChipColors({ @@ -373,32 +309,3 @@ describe('getChipColor - border color', () => { }); }); }); - -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__/FABExtended.test.tsx b/src/components/__tests__/FABExtended.test.tsx index fd9a21cec9..e8e530484d 100644 --- a/src/components/__tests__/FABExtended.test.tsx +++ b/src/components/__tests__/FABExtended.test.tsx @@ -1,9 +1,30 @@ -import { expect, it, jest } from '@jest/globals'; +import { Platform } from 'react-native'; + +import { afterEach, expect, it, jest } from '@jest/globals'; import { fireEvent, userEvent } from '@testing-library/react-native'; +import * as Reanimated from 'react-native-reanimated'; import { render, screen } from '../../test-utils'; import FAB from '../FAB'; +jest.mock('react-native-reanimated', () => { + const ReanimatedModule = jest.requireActual< + typeof import('react-native-reanimated') + >('react-native-reanimated'); + + return { + __esModule: true, + ...ReanimatedModule, + default: ReanimatedModule.default, + measure: jest.fn(), + }; +}); + +afterEach(() => { + jest.mocked(Reanimated.measure).mockReset(); + jest.restoreAllMocks(); +}); + it('renders extended FAB expanded', async () => { const tree = ( await render() @@ -11,6 +32,33 @@ it('renders extended FAB expanded', async () => { expect(tree).toMatchSnapshot(); }); +it('expands to fit the measured label width', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); + jest.mocked(Reanimated.measure).mockReturnValue({ + x: 0, + y: 0, + width: 80, + height: 20, + pageX: 0, + pageY: 0, + }); + + await render( + + ); + await jest.runAllTimersAsync(); + + expect( + Reanimated.getAnimatedStyle(screen.getByTestId('extended-fab-container')) + ).toMatchObject({ width: 144 }); +}); + it('renders extended FAB collapsed', async () => { const tree = ( await render( 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..5fdc76f64d 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'; @@ -10,13 +10,6 @@ import Button from '../Button/Button'; import Menu from '../Menu/Menu'; import Portal from '../Portal/Portal'; -const styles = StyleSheet.create({ - contentStyle: { - borderTopLeftRadius: 0, - borderTopRightRadius: 0, - }, -}); - it('renders visible menu', async () => { const tree = ( await render( @@ -55,31 +48,12 @@ it('renders not visible menu', async () => { expect(tree).toMatchSnapshot(); }); -it('renders menu with content styles', async () => { - const tree = ( - await render( - - Open menu} - contentStyle={styles.contentStyle} - > - - - - - ) - ).toJSON(); - - expect(tree).toMatchSnapshot(); -}); - const elevations: Elevation[] = [0, 1, 2, 3, 4, 5]; elevations.forEach((elevation) => it(`renders menu with background color based on elevation value = ${elevation}`, async () => { const theme = getTheme(); + const testID = 'menu-with-elevation'; await render( @@ -88,6 +62,8 @@ elevations.forEach((elevation) => onDismiss={jest.fn()} anchor={} elevation={elevation} + mode="flat" + testID={testID} > @@ -95,13 +71,14 @@ elevations.forEach((elevation) => ); - expect(screen.getByTestId('menu-surface')).toHaveStyle({ + expect(screen.getByTestId(`${testID}-surface`)).toHaveStyle({ backgroundColor: theme.colors.elevation[`level${elevation}`], }); }) ); it('uses the default anchorPosition of top', async () => { + const testID = 'top-positioned-menu'; const dimensionsSpy = jest.spyOn(Dimensions, 'get').mockReturnValue({ width: 400, height: 800, @@ -123,7 +100,7 @@ it('uses the default anchorPosition of top', async () => { Open menu } - contentStyle={styles.contentStyle} + testID={testID} > @@ -145,7 +122,7 @@ it('uses the default anchorPosition of top', async () => { }); await waitFor(() => { - const menu = screen.getByTestId('menu-view'); + const menu = screen.getByTestId(`${testID}-view`); expect(menu).toHaveStyle({ position: 'absolute', left: 100, @@ -158,6 +135,7 @@ it('uses the default anchorPosition of top', async () => { }); it('respects anchorPosition bottom', async () => { + const testID = 'bottom-positioned-menu'; const dimensionsSpy = jest.spyOn(Dimensions, 'get').mockReturnValue({ width: 400, height: 800, @@ -180,7 +158,7 @@ it('respects anchorPosition bottom', async () => { } anchorPosition="bottom" - contentStyle={styles.contentStyle} + testID={testID} > @@ -198,7 +176,7 @@ it('respects anchorPosition bottom', async () => { }); await waitFor(() => { - const menu = screen.getByTestId('menu-view'); + const menu = screen.getByTestId(`${testID}-view`); expect(menu).toHaveStyle({ position: 'absolute', left: 100, @@ -210,40 +188,9 @@ it('respects anchorPosition bottom', async () => { dimensionsSpy.mockRestore(); }); -it('animated value changes correctly', async () => { - const value = new Animated.Value(1); - await render( - - Open menu} - testID="menu" - contentStyle={[{ transform: [{ scale: value }] }]} - > - - - - ); - 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 }], - }); -}); - it('renders menu with mode "elevated"', async () => { + const testID = 'elevated-menu'; + await render( { onDismiss={jest.fn()} anchor={} mode="elevated" + testID={testID} > @@ -258,7 +206,7 @@ it('renders menu with mode "elevated"', async () => { ); - const menuSurface = screen.getByTestId('menu-surface'); + const menuSurface = screen.getByTestId(`${testID}-surface`); // Get flattened styles // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. @@ -269,6 +217,8 @@ it('renders menu with mode "elevated"', async () => { }); it('renders menu with mode "flat"', async () => { + const testID = 'flat-menu'; + await render( { onDismiss={jest.fn()} anchor={} mode="flat" + testID={testID} > @@ -283,7 +234,7 @@ it('renders menu with mode "flat"', async () => { ); - const menuSurface = screen.getByTestId('menu-surface'); + const menuSurface = screen.getByTestId(`${testID}-surface`); // Get flattened styles // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. diff --git a/src/components/__tests__/Modal.test.tsx b/src/components/__tests__/Modal.test.tsx index ff4cf2fd41..83d644e0e1 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, }); @@ -577,34 +575,4 @@ describe('Modal', () => { }); }); }); - - it('animated value changes correctly', async () => { - const value = new Animated.Value(1); - 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 }], - }); - }); }); 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..889e51a25a 100644 --- a/src/components/__tests__/Surface.test.tsx +++ b/src/components/__tests__/Surface.test.tsx @@ -1,6 +1,9 @@ -import type { ViewStyle } from 'react-native'; -import { StyleSheet } from 'react-native'; -import { Platform } from 'react-native'; +import { + DynamicColorIOS, + Platform, + PlatformColor, + Pressable, +} from 'react-native'; import { afterEach, @@ -10,34 +13,77 @@ import { it, jest, } from '@jest/globals'; +import { userEvent } from '@testing-library/react-native'; +import { + getAnimatedStyle, + useAnimatedStyle, + useSharedValue, +} from 'react-native-reanimated'; import { getTheme } from '../../core/theming'; import { render, screen } from '../../test-utils'; import Surface from '../Surface'; -type StyleCase = { - property: keyof ViewStyle; - value: ViewStyle[keyof ViewStyle]; -}; - const SPOT_SHADOW_OPACITY = 0.19; const AMBIENT_SHADOW_OPACITY = 0.039; +const AnimatedSurface = () => { + const opacity = useSharedValue(0); + const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value })); + + return ( + <> + { + opacity.value = 1; + }} + /> + + {null} + + + ); +}; + +const AnimatedVisualSurface = () => { + const borderRadius = useSharedValue(4); + + return ( + <> + { + borderRadius.value = 8; + }} + /> + + {null} + + + ); +}; + afterEach(() => { jest.restoreAllMocks(); }); 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' - ); + it('updates styles when a Reanimated shared value changes', async () => { + await render(); + + const surface = screen.getByTestId('animated-surface'); + expect(getAnimatedStyle(surface)).toMatchObject({ opacity: 0 }); + + await userEvent.press(screen.getByTestId('animate-surface')); + await jest.runAllTimersAsync(); + + expect(getAnimatedStyle(surface)).toMatchObject({ opacity: 1 }); }); describe('on iOS', () => { @@ -45,42 +91,13 @@ describe('Surface', () => { jest.replaceProperty(Platform, 'OS', 'ios'); }); - const styles = StyleSheet.create({ - absoluteStyles: { - bottom: 10, - end: 20, - left: 30, - position: 'absolute', - right: 40, - start: 50, - top: 60, - }, - innerLayerViewStyle: { - padding: 13, - }, - restStyle: { - padding: 10, - flexDirection: 'row', - alignContent: 'center', - }, - }); - 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({ - shadowOpacity: expect.any(Number), - }); // @ts-expect-error expect(screen.getByTestId('surface-test')).not.toHaveStyle({ shadowOpacity: expect.any(Number), @@ -90,192 +107,70 @@ describe('Surface', () => { }); }); - it('should render a spot shadow over an ambient shadow, if mode is elevated', async () => { + it('should render a spot shadow if mode is elevated', async () => { await render( {null} ); - expect(screen.getByTestId('surface-test-outer-layer')).toHaveStyle({ + expect(screen.getByTestId('surface-test')).toHaveStyle({ shadowOpacity: SPOT_SHADOW_OPACITY, }); + }); + + it('applies background and shape props in flat mode', async () => { + const backgroundColor = 'rgba(1, 2, 3, 0.5)'; + await render( + + {null} + + ); + expect(screen.getByTestId('surface-test')).toHaveStyle({ - shadowOpacity: AMBIENT_SHADOW_OPACITY, + backgroundColor, + borderRadius: 4, + borderTopLeftRadius: 8, }); }); - it.each([ - { property: 'opacity', value: 0.7 }, - { property: 'transform', value: [{ scale: 1.02 }] }, - { property: 'width', value: '42%' }, - { property: 'height', value: '32.5%' }, - { property: 'margin', value: 13 }, - { property: 'marginLeft', value: 13.1 }, - { property: 'marginRight', value: 13.2 }, - { property: 'marginTop', value: 13.3 }, - { property: 'marginBottom', value: 13.4 }, - { property: 'marginHorizontal', value: 13.5 }, - { property: 'marginVertical', value: 13.6 }, - { property: 'position', value: 'absolute' }, - { property: 'alignSelf', value: 'flex-start' }, - { property: 'top', value: 1.1 }, - { property: 'right', value: 1.2 }, - { property: 'bottom', value: 1.3 }, - { property: 'left', value: 1.4 }, - { 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 }, - { property: 'paddingTop', value: 12.3 }, - { property: 'paddingBottom', value: 12.4 }, - { property: 'paddingHorizontal', value: 12.5 }, - { property: 'paddingVertical', value: 12.6 }, - { property: 'borderWidth', value: 2 }, - { property: 'borderColor', value: 'black' }, - ] satisfies StyleCase[])( - 'applies $property to inner layer only', - async ({ property, value }) => { - const style = { [property]: value }; - - await render( - - {null} - - ); - - 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('updates the animated corner radius', async () => { + await render(); - it('should render absolute position properties on outer layer', async () => { - await render( - - {null} - - ); + const surface = screen.getByTestId('animated-visual-surface'); - expect(screen.getByTestId('surface-test-outer-layer')).toHaveStyle( - styles.absoluteStyles - ); + expect(getAnimatedStyle(surface)).toMatchObject({ + borderRadius: 4, }); - it('should render absolute position properties on the outer layer', async () => { - await render( - - {null} - - ); + await userEvent.press(screen.getByTestId('animate-visual-props')); + await jest.runAllTimersAsync(); - expect(screen.getByTestId('surface-test-outer-layer')).toHaveStyle( - styles.absoluteStyles - ); + expect(getAnimatedStyle(surface)).toMatchObject({ + borderRadius: 8, }); }); - 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 () => { - const backgroundColor = 'rgb(1, 2, 3)'; + it('does not transition a DynamicColorIOS background', async () => { 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); + expect( + getAnimatedStyle(screen.getByTestId('surface-test')) + ).toMatchObject({ + transitionProperty: expect.not.arrayContaining(['backgroundColor']), }); }); }); @@ -287,12 +182,7 @@ describe('Surface', () => { it('should render Surface with appropriate bg color but without shadow, if mode is set to "flat"', async () => { await render( - + {null} ); @@ -317,6 +207,23 @@ describe('Surface', () => { elevation: 12, }); }); + + it('does not transition a PlatformColor background', async () => { + await render( + + {null} + + ); + + expect( + getAnimatedStyle(screen.getByTestId('surface-container')) + ).toMatchObject({ + transitionProperty: expect.not.arrayContaining(['backgroundColor']), + }); + }); }); describe('on Web', () => { 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..3ffc31ac78 100644 --- a/src/components/__tests__/__snapshots__/Badge.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Badge.test.tsx.snap @@ -3,6 +3,7 @@ exports[`renders badge 1`] = ` @@ -280,132 +398,193 @@ 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. + + @@ -415,148 +594,242 @@ 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. - - - - + + + - + + - + + - - - first - - - + undefined, + ], + ], + ] + } + testID="button-text" + > + first + @@ -713,126 +1010,220 @@ 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. - - - - + + + - + + - + + - - - first - - - + undefined, + ], + ], + ] + } + testID="button-text" + > + first + - + - + + - + + - - - second - - - + undefined, + ], + ], + ] + } + testID="button-text" + > + second + @@ -1141,142 +1614,202 @@ 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. + + @@ -1286,120 +1819,180 @@ 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..6cf569a0c5 100644 --- a/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap @@ -74,42 +74,102 @@ exports[`allows customizing Route's type via generics 1`] = ` + + + + + + + + + + + - + + - + - - - Button with accessibility hint - - + ], + ] + } + testID="button-text" + > + Button with accessibility hint + @@ -155,34 +210,42 @@ 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 + @@ -310,31 +420,39 @@ exports[`renders button with button color 1`] = ` - + + - + - - - Custom Button - - - - + ], + ] + } + testID="button-text" + > + Custom Button + + + `; @@ -462,31 +627,39 @@ exports[`renders button with color 1`] = ` - + + - + - - - Custom Button - - + ], + ] + } + testID="button-text" + > + Custom Button + @@ -614,31 +834,39 @@ exports[`renders button with custom testID 1`] = ` - + + - + - - - Button with custom testID - - + ], + ] + } + testID="custom:testID-text" + > + Button with custom testID + @@ -766,31 +1041,39 @@ exports[`renders button with icon 1`] = ` - + + - + - - - camera - - - + camera + + + - Icon Button - - + ], + ] + } + testID="button-text" + > + Icon Button + @@ -967,31 +1297,39 @@ exports[`renders button with icon in reverse order 1`] = ` - + + - + - - - chevron-right - - - + chevron-right + + + - Right Icon - - + ], + ] + } + testID="button-text" + > + Right Icon + @@ -1170,31 +1555,39 @@ exports[`renders contained contained with mode 1`] = ` - + + - + - - - Contained Button - - + ], + ] + } + testID="button-text" + > + Contained Button + @@ -1323,31 +1763,39 @@ exports[`renders disabled button 1`] = ` - + + - + - - - Disabled Button - - + ], + ] + } + testID="button-text" + > + Disabled Button + @@ -1475,31 +1970,39 @@ exports[`renders loading button 1`] = ` - + + - + @@ -1610,13 +2175,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 +2189,8 @@ exports[`renders loading button 1`] = ` collapsable={false} style={ { - "height": 18, - "transform": [ - { - "rotate": "45deg", - }, - ], + "height": 9, + "overflow": "hidden", "width": 18, } } @@ -1638,8 +2199,15 @@ exports[`renders loading button 1`] = ` collapsable={false} style={ { - "height": 9, - "overflow": "hidden", + "height": 18, + "transform": [ + { + "translateY": 0, + }, + { + "rotate": "-165deg", + }, + ], "width": 18, } } @@ -1648,15 +2216,8 @@ exports[`renders loading button 1`] = ` collapsable={false} style={ { - "height": 18, - "transform": [ - { - "translateY": 0, - }, - { - "rotate": "-165deg", - }, - ], + "height": 9, + "overflow": "hidden", "width": 18, } } @@ -1665,40 +2226,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 +2271,9 @@ exports[`renders loading button 1`] = ` collapsable={false} style={ { - "height": 18, - "transform": [ - { - "rotate": "45deg", - }, - ], + "height": 9, + "overflow": "hidden", + "top": 9, "width": 18, } } @@ -1720,9 +2282,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 +2299,8 @@ exports[`renders loading button 1`] = ` collapsable={false} style={ { - "height": 18, - "transform": [ - { - "translateY": -9, - }, - { - "rotate": "345deg", - }, - ], + "height": 9, + "overflow": "hidden", "width": 18, } } @@ -1748,114 +2309,111 @@ 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 + `; -exports[`renders outlined button with mode 1`] = ` - - + + - + - - - Outlined Button - - + ], + ] + } + testID="button-text" + > + Outlined Button + @@ -1984,31 +2589,39 @@ exports[`renders text button by default 1`] = ` - + + - + - - - Text Button - - + ], + ] + } + testID="button-text" + > + Text Button + @@ -2136,31 +2796,39 @@ 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..04359a41f9 100644 --- a/src/components/__tests__/__snapshots__/Chip.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Chip.test.tsx.snap @@ -4,31 +4,41 @@ 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 +710,45 @@ 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..349fa1d224 100644 --- a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap @@ -382,280 +382,240 @@ exports[`DataTable.Pagination renders data table pagination 1`] = ` - + - + - - - chevron-left - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + chevron-left + - + - + - - - chevron-right - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + chevron-right + @@ -720,560 +680,480 @@ 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 + @@ -1338,280 +1218,240 @@ exports[`DataTable.Pagination renders data table pagination with label 1`] = ` - + - + - - - chevron-left - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + chevron-left + - + - + - - - chevron-right - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + chevron-right + @@ -1684,200 +1524,256 @@ exports[`DataTable.Pagination renders data table pagination with options select - + + - - - menu-down - - - + menu-down + + + - 2 - - + ], + ] + } + testID="button-text" + > + 2 + @@ -1924,560 +1820,480 @@ 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..391413183a 100644 --- a/src/components/__tests__/__snapshots__/FAB.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/FAB.test.tsx.snap @@ -2,15 +2,15 @@ exports[`renders FAB large size 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - - - camera - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + camera + @@ -146,139 +126,119 @@ exports[`renders icon button by default 1`] = ` - + - + - - - camera - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + camera + @@ -288,139 +248,119 @@ exports[`renders icon button with color 1`] = ` - + - + - - - camera - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + camera + @@ -430,139 +370,119 @@ exports[`renders icon button with size 1`] = ` - + - + - - - camera - - + ], + ] + } + > + camera + @@ -572,172 +492,152 @@ 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..032971c85e 100644 --- a/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap @@ -120,31 +120,41 @@ 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..afcd649f4f 100644 --- a/src/components/__tests__/__snapshots__/Menu.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Menu.test.tsx.snap @@ -1,25 +1,54 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`renders menu with content styles 1`] = ` -<> +exports[`renders not visible menu 1`] = ` + - - + - - - - Open menu - - - - - - - - - - + - - - - - - - Undo - - - - - - - - - Redo - - - - - + ], + ] + } + testID="button-text" + > + Open menu + - - -`; - -exports[`renders not visible menu 1`] = ` - + + +`; + +exports[`renders visible menu 1`] = ` +<> + - -`; - -exports[`renders visible menu 1`] = ` -<> - - - - - - - - Open menu - - - - - - - + + /> - - + + + - - - - magnify - - - + magnify + - + + + @@ -245,13 +292,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 +306,8 @@ exports[`activity indicator snapshot test 1`] = ` collapsable={false} style={ { - "height": 24, - "transform": [ - { - "rotate": "45deg", - }, - ], + "height": 12, + "overflow": "hidden", "width": 24, } } @@ -273,8 +316,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 +333,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 +343,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 +388,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 +399,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 +416,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 +426,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, } } - > - - + /> @@ -416,29 +448,34 @@ exports[`renders with placeholder 1`] = ` - - + + + - - - - magnify - - - + magnify + - + + + - - - - - close - - - + close + @@ -789,29 +833,34 @@ 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..095b214bb2 100644 --- a/src/components/__tests__/__snapshots__/Snackbar.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Snackbar.test.tsx.snap @@ -21,69 +21,122 @@ exports[`renders snackbar with Text as a child 1`] = ` } > - + + - - - - Snackbar content - - + + + Snackbar content + @@ -109,94 +162,147 @@ 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 +329,195 @@ exports[`renders snackbar with action button 1`] = ` } > - + - + - Snackbar content - + ], + ] + } + > + Snackbar content + + - - + + - + + - - - Undo - - - + false, + false, + { + "color": "rgba(208, 188, 255, 1)", + "fontFamily": "System", + "fontSize": 14, + "fontWeight": "500", + "letterSpacing": 0.1, + "lineHeight": 20, + }, + undefined, + ], + ], + ] + } + testID="button-text" + > + Undo + @@ -494,87 +710,140 @@ 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..e38a5145e1 100644 --- a/src/components/__tests__/__snapshots__/Switch.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Switch.test.tsx.snap @@ -96,6 +96,7 @@ exports[`Switch render renders disabled off 1`] = ` - + - + - - - magnify - - + ], + ] + } + > + magnify + - + - + - - - close - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + close + @@ -572,6 +548,7 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is } /> - + - + - - - magnify - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + magnify + - + - + - - - close - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + close + @@ -1074,6 +1026,7 @@ exports[`renders filled TextInput with label and value 1`] = ` } /> - + - + - - - magnify - - + ], + ] + } + > + magnify + - + - + - - - close - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + close + @@ -1753,6 +1682,7 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i /> - + - + - - - magnify - - + ], + ] + } + > + magnify + - + - + - - - close - - + ], + ] + } + > + close + @@ -2236,6 +2140,7 @@ exports[`renders outlined TextInput with label and value 1`] = ` /> + - - - heart - - + ], + ] + } + > + heart + @@ -144,137 +134,127 @@ exports[`renders toggle button 1`] = ` + - - - heart - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + heart + @@ -284,137 +264,127 @@ exports[`renders unchecked toggle button 1`] = ` + - - - heart - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + 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;