diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index a4b672640c..29e9f77529 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -63,7 +63,6 @@ const config = { docsRootDir: path.join(__dirname, 'docs', 'components'), libsRootDir: path.join(__dirname, '..', 'src', 'components'), pages: { - ActivityIndicator: 'ActivityIndicator', Appbar: { Appbar: 'Appbar/Appbar', AppbarAction: 'Appbar/AppbarAction', @@ -147,7 +146,14 @@ const config = { Portal: 'Portal/Portal', PortalHost: 'Portal/PortalHost', }, - ProgressBar: 'ProgressBar', + CircularProgressIndicator: + 'ProgressIndicator/CircularProgressIndicator/CircularProgressIndicator', + CircularWavyProgressIndicator: + 'ProgressIndicator/CircularWavyProgressIndicator/CircularWavyProgressIndicator', + LinearProgressIndicator: + 'ProgressIndicator/LinearProgressIndicator/LinearProgressIndicator', + LinearWavyProgressIndicator: + 'ProgressIndicator/LinearWavyProgressIndicator/LinearWavyProgressIndicator', RadioButton: { RadioButton: 'RadioButton/RadioButton', RadioButtonAndroid: 'RadioButton/RadioButtonAndroid', @@ -249,7 +255,7 @@ const config = { }, { type: 'doc', - docId: 'components/ActivityIndicator', + docId: 'components/Appbar/Appbar', position: 'left', label: 'Components', }, diff --git a/docs/src/components/BannerExample.tsx b/docs/src/components/BannerExample.tsx index fcb3e0a8e2..e1a6a2611b 100644 --- a/docs/src/components/BannerExample.tsx +++ b/docs/src/components/BannerExample.tsx @@ -11,7 +11,7 @@ import { FAB, DarkTheme, LightTheme, - ProgressBar, + LinearProgressIndicator, PaperProvider, RadioButton, Switch, @@ -89,7 +89,7 @@ const BannerExample = () => { - + Display Large Display Medium diff --git a/docs/src/data/screenshots.js b/docs/src/data/screenshots.js index f25cef6294..198912152c 100644 --- a/docs/src/data/screenshots.js +++ b/docs/src/data/screenshots.js @@ -1,5 +1,4 @@ const screenshots = { - ActivityIndicator: 'screenshots/activity-indicator.gif', Appbar: 'screenshots/appbar.png', 'Appbar.Action': 'screenshots/appbar-action-android.png', 'Appbar.BackAction': 'screenshots/appbar-backaction-android.png', @@ -105,7 +104,7 @@ const screenshots = { }, 'Menu.Item': 'screenshots/menu-item.png', Modal: 'screenshots/modal.gif', - ProgressBar: 'screenshots/progress-bar.png', + LinearProgressIndicator: 'screenshots/progress-bar.png', RadioButton: { 'Android (enabled)': 'screenshots/radio-enabled.android.png', 'Android (disabled)': 'screenshots/radio-disabled.android.png', diff --git a/docs/src/data/themeColors.js b/docs/src/data/themeColors.js index 2e97dd1bce..4248fd2056 100644 --- a/docs/src/data/themeColors.js +++ b/docs/src/data/themeColors.js @@ -1,9 +1,4 @@ const themeColors = { - ActivityIndicator: { - '-': { - borderColor: 'theme.colors.primary', - }, - }, Appbar: { default: { backgroundColor: 'theme.colors.surface', @@ -264,10 +259,10 @@ const themeColors = { backgroundColor: 'theme.colors.backdrop', }, }, - ProgressBar: { + LinearProgressIndicator: { '-': { - tintColor: 'theme.colors.primary', - trackTintColor: 'theme.colors.surfaceVariant', + color: 'theme.colors.primary', + trackColor: 'theme.colors.secondaryContainer', }, }, Searchbar: { diff --git a/example/package.json b/example/package.json index 992ead9c23..b8bd97faf3 100644 --- a/example/package.json +++ b/example/package.json @@ -32,6 +32,7 @@ "react-native-reanimated": "4.3.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "4.25.1", + "react-native-svg": "15.15.4", "react-native-web": "^0.21.0", "react-native-worklets": "0.8.3" }, diff --git a/example/src/ExampleList.tsx b/example/src/ExampleList.tsx index 18c958a4bb..1ac1a142d6 100644 --- a/example/src/ExampleList.tsx +++ b/example/src/ExampleList.tsx @@ -5,7 +5,6 @@ import { useNavigation } from '@react-navigation/native'; import { Divider, List, useTheme } from 'react-native-paper'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import ActivityIndicatorExample from './Examples/ActivityIndicatorExample'; import AppbarExample from './Examples/AppbarExample'; import AvatarExample from './Examples/AvatarExample'; import BadgeExample from './Examples/BadgeExample'; @@ -28,7 +27,7 @@ import ListAccordionExampleGroup from './Examples/ListAccordionGroupExample'; import ListItemExample from './Examples/ListItemExample'; import ListSectionExample from './Examples/ListSectionExample'; import MenuExample from './Examples/MenuExample'; -import ProgressBarExample from './Examples/ProgressBarExample'; +import ProgressIndicatorExample from './Examples/ProgressIndicatorExample'; import RadioButtonExample from './Examples/RadioButtonExample'; import RadioButtonGroupExample from './Examples/RadioButtonGroupExample'; import RadioButtonItemExample from './Examples/RadioButtonItemExample'; @@ -50,7 +49,6 @@ import TooltipExample from './Examples/TooltipExample'; import TouchableRippleExample from './Examples/TouchableRippleExample'; export const mainExamples = { - ActivityIndicator: ActivityIndicatorExample, Appbar: AppbarExample, Avatar: AvatarExample, Badge: BadgeExample, @@ -73,7 +71,7 @@ export const mainExamples = { ListSection: ListSectionExample, ListItem: ListItemExample, Menu: MenuExample, - Progressbar: ProgressBarExample, + ProgressIndicator: ProgressIndicatorExample, Radio: RadioButtonExample, RadioGroup: RadioButtonGroupExample, RadioItem: RadioButtonItemExample, diff --git a/example/src/Examples/ActivityIndicatorExample.tsx b/example/src/Examples/ActivityIndicatorExample.tsx deleted file mode 100644 index e5fc90f032..0000000000 --- a/example/src/Examples/ActivityIndicatorExample.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import * as React from 'react'; -import { StyleSheet, View } from 'react-native'; - -import { ActivityIndicator, FAB, List, Palette } from 'react-native-paper'; - -import ScreenWrapper from '../ScreenWrapper'; - -const ActivityIndicatorExample = () => { - const [animating, setAnimating] = React.useState(true); - - return ( - - - setAnimating(!animating)} - /> - - - - - - - - - - - - - - - - - - - ); -}; - -ActivityIndicatorExample.title = 'Activity Indicator'; - -const styles = StyleSheet.create({ - container: { - padding: 4, - }, - row: { - justifyContent: 'center', - alignItems: 'center', - margin: 10, - }, -}); - -export default ActivityIndicatorExample; diff --git a/example/src/Examples/Dialogs/DialogWithLoadingIndicator.tsx b/example/src/Examples/Dialogs/DialogWithLoadingIndicator.tsx index 86cbe67c0d..f75ab40eb0 100644 --- a/example/src/Examples/Dialogs/DialogWithLoadingIndicator.tsx +++ b/example/src/Examples/Dialogs/DialogWithLoadingIndicator.tsx @@ -1,12 +1,15 @@ import * as React from 'react'; -import { ActivityIndicator, Platform, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; -import { Dialog, Palette, Portal } from 'react-native-paper'; +import { + CircularProgressIndicator, + Dialog, + Palette, + Portal, +} from 'react-native-paper'; import { TextComponent } from './DialogTextComponent'; -const isIOS = Platform.OS === 'ios'; - const DialogWithLoadingIndicator = ({ visible, close, @@ -20,9 +23,9 @@ const DialogWithLoadingIndicator = ({ Progress Dialog - Loading..... diff --git a/example/src/Examples/ProgressBarExample.tsx b/example/src/Examples/ProgressBarExample.tsx deleted file mode 100644 index 5b59566d6e..0000000000 --- a/example/src/Examples/ProgressBarExample.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import * as React from 'react'; -import { View, StyleSheet, Animated } from 'react-native'; - -import { - Button, - Palette, - ProgressBar, - ProgressBarProps, - Text, - useTheme, -} from 'react-native-paper'; - -import ScreenWrapper from '../ScreenWrapper'; - -class ClassProgressBar extends React.Component { - constructor(props: ProgressBarProps) { - super(props); - } - - render() { - return ; - } -} - -const AnimatedProgressBar = Animated.createAnimatedComponent(ClassProgressBar); - -const ProgressBarExample = () => { - const [visible, setVisible] = React.useState(true); - const [progress, setProgress] = React.useState(0.3); - const theme = useTheme(); - const { current: progressBarValue } = React.useRef(new Animated.Value(0)); - - const runCustomAnimation = () => { - progressBarValue.setValue(0); - Animated.timing(progressBarValue, { - toValue: 1, - duration: 2000, - useNativeDriver: false, - }).start(); - }; - - return ( - - - - - - - Default ProgressBar - - - - - Indeterminate ProgressBar - - - - - ProgressBar with custom color - - - - - - ProgressBar with custom background color - - - - - - ProgressBar with custom height - - - - - ProgressBar with animated value - - - - - - ProgressBar with custom percentage height - - - - - ); -}; - -ProgressBarExample.title = 'Progress Bar'; - -const styles = StyleSheet.create({ - container: { - padding: 16, - }, - row: { - marginVertical: 10, - }, - fullRow: { - height: '100%', - width: '100%', - }, - customHeight: { - height: 20, - }, - customPercentageHeight: { - height: '50%', - }, - progressBar: { - height: 15, - }, -}); - -export default ProgressBarExample; diff --git a/example/src/Examples/ProgressIndicatorExample.tsx b/example/src/Examples/ProgressIndicatorExample.tsx new file mode 100644 index 0000000000..37d4ec5c61 --- /dev/null +++ b/example/src/Examples/ProgressIndicatorExample.tsx @@ -0,0 +1,278 @@ +import * as React from 'react'; +import { ScrollView, StyleSheet, View } from 'react-native'; + +import { + Chip, + CircularProgressIndicator, + CircularWavyProgressIndicator, + FAB, + LinearProgressIndicator, + LinearWavyProgressIndicator, + List, + Switch, + Text, + useTheme, +} from 'react-native-paper'; +import { + cancelAnimation, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { scheduleOnRN } from 'react-native-worklets'; + +import ScreenWrapper from '../ScreenWrapper'; + +const sizes = ['small', 'medium', 'large'] as const; +const shapes = ['flat', 'wavy'] as const; + +type Size = (typeof sizes)[number]; +type Shape = (typeof shapes)[number]; + +// Linear keeps the same thickness scale; circular grows its diameter on top of that. Flat and +// wavy circular have different baselines (40 vs 48dp), so each shape has its own size scale. +const sizeMap: Record< + Size, + { thickness: number; flatSize: number; wavySize: number } +> = { + small: { thickness: 4, flatSize: 40, wavySize: 48 }, + medium: { thickness: 6, flatSize: 44, wavySize: 52 }, + large: { thickness: 8, flatSize: 48, wavySize: 56 }, +}; + +type ChipRowProps = { + label: string; + options: readonly T[]; + value: T; + onChange: (value: T) => void; +}; + +const ChipRow = ({ + label, + options, + value, + onChange, +}: ChipRowProps) => ( + + + {label} + + + {options.map((option) => ( + onChange(option)} + > + {option} + + ))} + + +); + +const ProgressIndicatorExample = () => { + const [determinate, setDeterminate] = React.useState(true); + const [size, setSize] = React.useState('small'); + const [shape, setShape] = React.useState('flat'); + const [running, setRunning] = React.useState(false); + const progress = useSharedValue(0); + + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + const indeterminate = !determinate; + + const { thickness, flatSize, wavySize } = sizeMap[size]; + const wavy = shape === 'wavy'; + const Linear = wavy ? LinearWavyProgressIndicator : LinearProgressIndicator; + const Circular = wavy + ? CircularWavyProgressIndicator + : CircularProgressIndicator; + const circularSize = wavy ? wavySize : flatSize; + + const linearWavyHeight = Math.max(10, thickness + 2 * 3); + const linearPad = wavy ? 0 : (linearWavyHeight - thickness) / 2; + const circularPad = wavy ? 0 : (wavySize - flatSize) / 2; + + const fabPadding = 16; + const FILL_DURATION = 3000; + + const start = () => { + const from = progress.value >= 1 ? 0 : progress.value; + progress.value = from; + progress.value = withTiming( + 1, + { duration: FILL_DURATION * (1 - from) }, + (finished) => { + if (finished) { + scheduleOnRN(setRunning, false); + } + } + ); + setRunning(true); + }; + + const pause = () => { + cancelAnimation(progress); + setRunning(false); + }; + + const handleFabPress = () => { + if (running) { + pause(); + } else { + start(); + } + }; + + const handleDeterminateChange = (next: boolean) => { + setDeterminate(next); + if (next) { + start(); + } else { + cancelAnimation(progress); + setRunning(false); + } + }; + + React.useEffect(() => () => cancelAnimation(progress), [progress]); + + return ( + + + ( + + )} + /> + + + + + Linear + + + + + + + + + + + + + Circular + + + + + + + + + + + ); +}; + +ProgressIndicatorExample.title = 'Progress Indicator'; + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + container: { + paddingVertical: 16, + }, + chipRow: { + paddingVertical: 4, + }, + chipRowLabel: { + paddingHorizontal: 16, + paddingBottom: 6, + }, + chipRowContent: { + paddingHorizontal: 16, + gap: 8, + }, + row: { + paddingHorizontal: 16, + marginVertical: 16, + gap: 12, + }, + circularRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 24, + }, + fabContainer: { + position: 'absolute', + flexDirection: 'row', + justifyContent: 'center', + }, +}); + +export default ProgressIndicatorExample; diff --git a/jest/testSetup.js b/jest/testSetup.js index c00e611084..30a790b274 100644 --- a/jest/testSetup.js +++ b/jest/testSetup.js @@ -12,6 +12,34 @@ jest.mock('react-native-reanimated', () => require('react-native-reanimated/mock') ); +jest.mock('react-native-svg', () => { + const React = require('react'); + const { View } = require('react-native'); + + const makeComponent = (name) => { + const Component = ({ children, ...props }) => + React.createElement(View, props, children); + Component.displayName = name; + return Component; + }; + + const Svg = makeComponent('Svg'); + + return { + __esModule: true, + default: Svg, + Svg, + Circle: makeComponent('Circle'), + Path: makeComponent('Path'), + G: makeComponent('G'), + Line: makeComponent('Line'), + Rect: makeComponent('Rect'), + Defs: makeComponent('Defs'), + LinearGradient: makeComponent('LinearGradient'), + Stop: makeComponent('Stop'), + }; +}); + jest.mock('@react-native-vector-icons/material-design-icons', () => { const React = require('react'); const { Text } = require('react-native'); diff --git a/package.json b/package.json index 34ae7e3d9e..a2d67d44e9 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "react-native-builder-bob": "^0.41.0", "react-native-reanimated": "4.3.1", "react-native-safe-area-context": "5.7.0", + "react-native-svg": "15.15.4", "react-native-worklets": "0.8.3", "react-test-renderer": "19.2.3", "release-it": "^13.4.0", @@ -107,6 +108,7 @@ "react-native": "*", "react-native-reanimated": ">=4.3.0", "react-native-safe-area-context": "*", + "react-native-svg": ">=15.0.0", "react-native-worklets": ">=0.8.1" }, "husky": { @@ -136,7 +138,7 @@ "__fixtures__\\/[^/]+\\/(output|error)\\.js" ], "transformIgnorePatterns": [ - "node_modules/(?!(@react-native|react-native|@react-navigation|react-native-reanimated|react-native-worklets|react-native-safe-area-context|nanoid|query-string|decode-uri-component|filter-obj|split-on-first|escape-string-regexp)/)" + "node_modules/(?!(@react-native|react-native|@react-navigation|react-native-reanimated|react-native-worklets|react-native-safe-area-context|react-native-svg|nanoid|query-string|decode-uri-component|filter-obj|split-on-first|escape-string-regexp)/)" ] }, "greenkeeper": { diff --git a/src/components/ActivityIndicator.tsx b/src/components/ActivityIndicator.tsx deleted file mode 100644 index 95da56ae0c..0000000000 --- a/src/components/ActivityIndicator.tsx +++ /dev/null @@ -1,255 +0,0 @@ -import * as React from 'react'; -import { - Animated, - ColorValue, - Easing, - Platform, - StyleProp, - StyleSheet, - View, - ViewStyle, -} from 'react-native'; - -import { useInternalTheme } from '../core/theming'; -import type { ThemeProp } from '../types'; - -export type Props = React.ComponentPropsWithRef & { - /** - * Whether to show the indicator or hide it. - */ - animating?: boolean; - /** - * The color of the spinner. - */ - color?: ColorValue; - /** - * Size of the indicator. - */ - size?: 'small' | 'large' | number; - /** - * Whether the indicator should hide when not animating. - */ - hidesWhenStopped?: boolean; - style?: StyleProp; - /** - * @optional - */ - theme?: ThemeProp; -}; - -const DURATION = 2400; - -/** - * Activity indicator is used to present progress of some activity in the app. - * It can be used as a drop-in replacement for the ActivityIndicator shipped with React Native. - * - * ## Usage - * ```js - * import * as React from 'react'; - * import { ActivityIndicator, Palette } from 'react-native-paper'; - * - * const MyComponent = () => ( - * - * ); - * - * export default MyComponent; - * ``` - */ -const ActivityIndicator = ({ - animating = true, - color: indicatorColor, - hidesWhenStopped = true, - size: indicatorSize = 'small', - style, - theme: themeOverrides, - ...rest -}: Props) => { - const theme = useInternalTheme(themeOverrides); - const { current: timer } = React.useRef( - new Animated.Value(0) - ); - const { current: fade } = React.useRef( - new Animated.Value(!animating && hidesWhenStopped ? 0 : 1) - ); - - const rotation = React.useRef( - undefined - ); - - const { - animation: { scale }, - } = theme; - - const startRotation = React.useCallback(() => { - // Show indicator - Animated.timing(fade, { - duration: 200 * scale, - toValue: 1, - isInteraction: false, - useNativeDriver: true, - }).start(); - - // Circular animation in loop - if (rotation.current) { - timer.setValue(0); - // $FlowFixMe - Animated.loop(rotation.current).start(); - } - }, [scale, fade, timer]); - - const stopRotation = () => { - if (rotation.current) { - rotation.current.stop(); - } - }; - - React.useEffect(() => { - if (rotation.current === undefined) { - // Circular animation in loop - rotation.current = Animated.timing(timer, { - duration: DURATION, - easing: Easing.linear, - // Animated.loop does not work if useNativeDriver is true on web - useNativeDriver: Platform.OS !== 'web', - toValue: 1, - isInteraction: false, - }); - } - - if (animating) { - startRotation(); - } else if (hidesWhenStopped) { - // Hide indicator first and then stop rotation - Animated.timing(fade, { - duration: 200 * scale, - toValue: 0, - useNativeDriver: true, - isInteraction: false, - }).start(stopRotation); - } else { - stopRotation(); - } - }, [animating, fade, hidesWhenStopped, startRotation, scale, timer]); - - const color = indicatorColor || theme.colors?.primary; - const size = - typeof indicatorSize === 'string' - ? indicatorSize === 'small' - ? 24 - : 48 - : indicatorSize - ? indicatorSize - : 24; - - const frames = (60 * DURATION) / 1000; - const easing = Easing.bezier(0.4, 0.0, 0.7, 1.0); - const containerStyle = { - width: size, - height: size / 2, - overflow: 'hidden' as const, - }; - - return ( - - - {[0, 1].map((index) => { - // Thanks to https://github.com/n4kz/react-native-indicators for the great work - const inputRange = Array.from( - new Array(frames), - (_, frameIndex) => frameIndex / (frames - 1) - ); - const outputRange = Array.from(new Array(frames), (_, frameIndex) => { - let progress = (2 * frameIndex) / (frames - 1); - const rotation = index ? +(360 - 15) : -(180 - 15); - - if (progress > 1.0) { - progress = 2.0 - progress; - } - - const direction = index ? -1 : +1; - - return `${direction * (180 - 30) * easing(progress) + rotation}deg`; - }); - - const layerStyle = { - width: size, - height: size, - transform: [ - { - rotate: timer.interpolate({ - inputRange: [0, 1], - outputRange: [`${0 + 30 + 15}deg`, `${2 * 360 + 30 + 15}deg`], - }), - }, - ], - }; - - const viewportStyle = { - width: size, - height: size, - transform: [ - { - translateY: index ? -size / 2 : 0, - }, - { - rotate: timer.interpolate({ inputRange, outputRange }), - }, - ], - }; - - const offsetStyle = index ? { top: size / 2 } : null; - - const lineStyle = { - width: size, - height: size, - borderColor: color, - borderWidth: size / 10, - borderRadius: size / 2, - }; - - return ( - - - - - - - - - - - - ); - })} - - - ); -}; - -const styles = StyleSheet.create({ - container: { - justifyContent: 'center', - alignItems: 'center', - }, - - layer: { - ...StyleSheet.absoluteFill, - - justifyContent: 'center', - alignItems: 'center', - }, -}); - -export default ActivityIndicator; diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 17d40fb035..f2aaa9818a 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -23,8 +23,8 @@ import type { $Omit, Theme, ThemeProp } from '../../types'; import { forwardRef } from '../../utils/forwardRef'; import hasTouchHandler from '../../utils/hasTouchHandler'; import { splitStyles } from '../../utils/splitStyles'; -import ActivityIndicator from '../ActivityIndicator'; import Icon, { IconSource } from '../Icon'; +import CircularProgressIndicator from '../ProgressIndicator/CircularProgressIndicator'; import Surface from '../Surface'; import TouchableRipple, { Props as TouchableRippleProps, @@ -387,8 +387,10 @@ const Button = ( ) : null} {loading ? ( - ( > {loading ? ( - + ) : ( )} diff --git a/src/components/ProgressBar.tsx b/src/components/ProgressBar.tsx deleted file mode 100644 index bf1941490f..0000000000 --- a/src/components/ProgressBar.tsx +++ /dev/null @@ -1,289 +0,0 @@ -import * as React from 'react'; -import { - Animated, - LayoutChangeEvent, - Platform, - StyleProp, - StyleSheet, - View, - ViewStyle, -} from 'react-native'; - -import { useLocale } from '../core/locale'; -import { useInternalTheme } from '../core/theming'; -import type { ThemeProp } from '../types'; - -export type Props = React.ComponentPropsWithRef & { - /** - * Animated value (between 0 and 1). This tells the progress bar to rely on this value to animate it. - * Note: It should not be used in parallel with the `progress` prop. - */ - animatedValue?: number; - /** - * Progress value (between 0 and 1). - * Note: It should not be used in parallel with the `animatedValue` prop. - */ - progress?: number; - /** - * Color of the progress bar. The background color will be calculated based on this but you can change it by passing `backgroundColor` to `style` prop. - */ - color?: string; - /** - * If the progress bar will show indeterminate progress. - */ - indeterminate?: boolean; - /** - * Whether to show the ProgressBar (true, the default) or hide it (false). - */ - visible?: boolean; - /** - * Style of filled part of the ProgresBar. - */ - fillStyle?: Animated.WithAnimatedValue>; - style?: StyleProp; - /** - * @optional - */ - theme?: ThemeProp; - /** - * testID to be used on tests. - */ - testID?: string; -}; - -const INDETERMINATE_DURATION = 2000; -const INDETERMINATE_MAX_WIDTH = 0.6; - -/** - * Progress bar is an indicator used to present progress of some activity in the app. - * - * ## Usage - * ```js - * import * as React from 'react'; - * import { ProgressBar, Palette } from 'react-native-paper'; - * - * const MyComponent = () => ( - * - * ); - * - * export default MyComponent; - * ``` - */ -const ProgressBar = ({ - color, - indeterminate, - progress = 0, - visible = true, - theme: themeOverrides, - animatedValue, - style, - fillStyle, - testID = 'progress-bar', - ...rest -}: Props) => { - const isWeb = Platform.OS === 'web'; - const theme = useInternalTheme(themeOverrides); - const { direction } = useLocale(); - const isRTL = direction === 'rtl'; - const { current: timer } = React.useRef( - new Animated.Value(0) - ); - const { current: fade } = React.useRef(new Animated.Value(0)); - const passedAnimatedValue = - React.useRef(animatedValue); - const [width, setWidth] = React.useState(0); - const [prevWidth, setPrevWidth] = React.useState(0); - - const indeterminateAnimation = - React.useRef(null); - - const { scale } = theme.animation; - - React.useEffect(() => { - passedAnimatedValue.current = animatedValue; - }); - - const startAnimation = React.useCallback(() => { - // Show progress bar - Animated.timing(fade, { - duration: 200 * scale, - toValue: 1, - useNativeDriver: true, - isInteraction: false, - }).start(); - - /** - * We shouldn't add @param animatedValue to the - * deps array, to avoid the unnecessary loop. - * We can only check if the prop is passed initially, - * and we do early return. - */ - const externalAnimation = - typeof passedAnimatedValue.current !== 'undefined' && - passedAnimatedValue.current >= 0; - - if (externalAnimation) { - return; - } - - // Animate progress bar - if (indeterminate) { - if (!indeterminateAnimation.current) { - indeterminateAnimation.current = Animated.timing(timer, { - duration: INDETERMINATE_DURATION, - toValue: 1, - // Animated.loop does not work if useNativeDriver is true on web - useNativeDriver: !isWeb, - isInteraction: false, - }); - } - - // Reset timer to the beginning - timer.setValue(0); - - Animated.loop(indeterminateAnimation.current).start(); - } else { - Animated.timing(timer, { - duration: 200 * scale, - toValue: progress ? progress : 0, - useNativeDriver: true, - isInteraction: false, - }).start(); - } - }, [fade, scale, indeterminate, timer, progress, isWeb]); - - const stopAnimation = React.useCallback(() => { - // Stop indeterminate animation - if (indeterminateAnimation.current) { - indeterminateAnimation.current.stop(); - } - - Animated.timing(fade, { - duration: 200 * scale, - toValue: 0, - useNativeDriver: true, - isInteraction: false, - }).start(); - }, [fade, scale]); - - React.useEffect(() => { - if (visible) startAnimation(); - else stopAnimation(); - }, [visible, startAnimation, stopAnimation]); - - React.useEffect(() => { - if (animatedValue && animatedValue >= 0) { - timer.setValue(animatedValue); - } - }, [animatedValue, timer]); - - React.useEffect(() => { - // Start animation the very first time when previously the width was unclear - if (visible && prevWidth === 0) { - startAnimation(); - } - }, [prevWidth, startAnimation, visible]); - - const onLayout = (event: LayoutChangeEvent) => { - setPrevWidth(width); - setWidth(event.nativeEvent.layout.width); - }; - - const tintColor = color || theme.colors?.primary; - const trackTintColor = theme.colors.surfaceVariant; - - return ( - - - {width ? ( - - ) : null} - - - ); -}; - -const styles = StyleSheet.create({ - container: { - height: 4, - overflow: 'hidden', - }, - webContainer: { - width: '100%', - height: '100%', - }, - progressBar: { - flex: 1, - }, -}); - -export default ProgressBar; diff --git a/src/components/ProgressIndicator/CircularProgressIndicator/CircularProgressIndicator.tsx b/src/components/ProgressIndicator/CircularProgressIndicator/CircularProgressIndicator.tsx new file mode 100644 index 0000000000..518b552102 --- /dev/null +++ b/src/components/ProgressIndicator/CircularProgressIndicator/CircularProgressIndicator.tsx @@ -0,0 +1,231 @@ +import * as React from 'react'; +import { + type ColorValue, + type StyleProp, + StyleSheet, + View, + type ViewStyle, +} from 'react-native'; + +import Animated, { + type SharedValue, + useAnimatedProps, + useAnimatedStyle, + useDerivedValue, +} from 'react-native-reanimated'; +import Svg, { Circle } from 'react-native-svg'; + +import { CircularProgressIndicatorTokens as T } from './tokens'; +import { useInternalTheme } from '../../../core/theming'; +import type { ThemeProp } from '../../../types'; +import { + CIRCULAR_INDETERMINATE_DURATION as CYCLE, + circularArc, +} from '../circularIndeterminateSpecs'; +import { useIndeterminatePhase, useProgressAccessibility } from '../hooks'; +import { getProgressIndicatorColors } from '../utils'; + +export type Props = { + /** + * Progress between 0 and 1 (out-of-range values are clamped). Pass a number to set it + * directly, or a Reanimated `SharedValue` to animate it on the UI thread. + */ + progress?: number | SharedValue; + /** + * Show progress of an unknown duration. While set, `progress` is ignored. + */ + indeterminate?: boolean; + /** + * Color of the active indicator. Defaults to `theme.colors.primary`. + */ + color?: ColorValue; + /** + * Color of the track. Defaults to `theme.colors.secondaryContainer`. The track is hidden + * while indeterminate. + */ + trackColor?: ColorValue; + /** + * Indicator diameter + */ + size?: number; + /** + * Indicator stroke thickness + */ + thickness?: number; + style?: StyleProp; + /** + * @optional + */ + theme?: ThemeProp; + /** + * testID to be used on tests. + */ + testID?: string; + /** + * Label describing what is loading, e.g. "Loading news article". + */ + accessibilityLabel?: string; +}; + +const AnimatedCircle = Animated.createAnimatedComponent(Circle); + +/** + * Material Design 3 circular progress indicator. Supports determinate and indeterminate modes. + * + * ## Usage + * ```js + * import * as React from 'react'; + * import { CircularProgressIndicator } from 'react-native-paper'; + * + * const MyComponent = () => ( + * + * ); + * + * export default MyComponent; + * ``` + */ +const CircularProgressIndicator = ({ + progress = 0, + indeterminate = false, + color, + trackColor, + size = T.size, + thickness = T.activeThickness, + style, + theme: themeOverrides, + testID = 'circular-progress-indicator', + accessibilityLabel, +}: Props) => { + const theme = useInternalTheme(themeOverrides); + + const { activeColor, trackColor: resolvedTrackColor } = + getProgressIndicatorColors({ theme, color, trackColor }); + + const center = size / 2; + const radius = (size - thickness) / 2; + const circumference = 2 * Math.PI * radius; + const capAngle = (thickness / 2 / circumference) * 360; + const gapAngle = (T.trackActiveSpace / circumference) * 360; + + const { progressPercent, busy } = useProgressAccessibility( + progress, + indeterminate + ); + + const phase = useIndeterminatePhase(indeterminate, CYCLE); + + const arc = useDerivedValue(() => { + if (indeterminate) { + const { startAngle, sweepAngle } = circularArc(phase.value * CYCLE); + const sweepLen = Math.max((sweepAngle / 360) * circumference, 0.01); + return { rotate: startAngle, sweepLen }; + } + const p = Math.min( + 1, + Math.max(0, typeof progress === 'number' ? progress : progress.value) + ); + return { rotate: -90, sweepLen: p * circumference }; + }, [indeterminate, progress, circumference]); + + const activeRotation = useAnimatedStyle( + () => ({ transform: [{ rotate: `${arc.value.rotate}deg` }] }), + [] + ); + + // dasharray [C, C] makes the visible length = C - dashoffset, anchored at the stroke start. + const activeProps = useAnimatedProps( + () => ({ strokeDashoffset: circumference - arc.value.sweepLen }), + [circumference] + ); + + const trackRotation = useAnimatedStyle(() => { + const p = Math.min( + 1, + Math.max(0, typeof progress === 'number' ? progress : progress.value) + ); + return { + transform: [{ rotate: `${-90 + p * 360 + 2 * capAngle + gapAngle}deg` }], + }; + }, [progress, capAngle, gapAngle]); + + const trackProps = useAnimatedProps(() => { + const p = Math.min( + 1, + Math.max(0, typeof progress === 'number' ? progress : progress.value) + ); + const trackLenAngle = + p <= 0 ? 360 : 360 - p * 360 - 4 * capAngle - 2 * gapAngle; + const trackLen = Math.max(0, (trackLenAngle / 360) * circumference); + return { + strokeDashoffset: circumference - trackLen, + strokeOpacity: trackLenAngle <= 0 ? 0 : 1, + }; + }, [progress, circumference, capAngle, gapAngle]); + + const circleProps = { + cx: center, + cy: center, + r: radius, + fill: 'none', + strokeWidth: thickness, + strokeLinecap: 'round' as const, + strokeDasharray: [circumference, circumference], + }; + + return ( + + {!indeterminate ? ( + + + + + + ) : null} + + + + + + + ); +}; + +const styles = StyleSheet.create({ + layer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, +}); + +export default CircularProgressIndicator; diff --git a/src/components/ProgressIndicator/CircularProgressIndicator/__tests__/CircularProgressIndicator.test.tsx b/src/components/ProgressIndicator/CircularProgressIndicator/__tests__/CircularProgressIndicator.test.tsx new file mode 100644 index 0000000000..cd4c9a5691 --- /dev/null +++ b/src/components/ProgressIndicator/CircularProgressIndicator/__tests__/CircularProgressIndicator.test.tsx @@ -0,0 +1,75 @@ +import * as React from 'react'; + +import { render } from '../../../../test-utils'; +import CircularProgressIndicator from '../CircularProgressIndicator'; + +const a11yRole = 'progressbar'; + +// One smoke snapshot; behavior is checked with the assertions below. +it('renders determinate progress', () => { + const tree = render().toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('resolves active and track colors', () => { + const tree = render( + + ); + + expect(tree.getByTestId('pi-active').props.stroke).toBe('red'); + expect(tree.getByTestId('pi-track').props.stroke).toBe('blue'); +}); + +it('renders the active stroke and no track when indeterminate', () => { + const tree = render(); + + expect(tree.getByTestId('pi-active')).toBeTruthy(); + expect(tree.queryByTestId('pi-track')).toBeNull(); + expect(tree.getByRole(a11yRole).props.accessibilityValue).toEqual({}); +}); + +it('reports the determinate value', () => { + const tree = render(); + + expect(tree.getByRole(a11yRole).props.accessibilityValue).toEqual({ + min: 0, + max: 100, + now: 42, + }); +}); + +it('is busy below 100% and not busy at 100% (determinate)', () => { + const partial = render(); + expect(partial.getByRole(a11yRole).props.accessibilityState).toEqual({ + busy: true, + }); + + const complete = render(); + expect(complete.getByRole(a11yRole).props.accessibilityState).toEqual({ + busy: false, + }); +}); + +it('is busy when indeterminate', () => { + const tree = render(); + + expect(tree.getByRole(a11yRole).props.accessibilityState).toEqual({ + busy: true, + }); +}); + +it('shows the track again when toggling indeterminate -> determinate', () => { + const tree = render(); + expect(tree.queryByTestId('pi-track')).toBeNull(); + + tree.rerender(); + + expect(tree.getByTestId('pi-track')).toBeTruthy(); + expect(tree.getByTestId('pi-active')).toBeTruthy(); +}); diff --git a/src/components/ProgressIndicator/CircularProgressIndicator/__tests__/__snapshots__/CircularProgressIndicator.test.tsx.snap b/src/components/ProgressIndicator/CircularProgressIndicator/__tests__/__snapshots__/CircularProgressIndicator.test.tsx.snap new file mode 100644 index 0000000000..1155c28e61 --- /dev/null +++ b/src/components/ProgressIndicator/CircularProgressIndicator/__tests__/__snapshots__/CircularProgressIndicator.test.tsx.snap @@ -0,0 +1,128 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders determinate progress 1`] = ` + + + + + + + + + + + + +`; diff --git a/src/components/ProgressIndicator/CircularProgressIndicator/index.tsx b/src/components/ProgressIndicator/CircularProgressIndicator/index.tsx new file mode 100644 index 0000000000..6b2d771532 --- /dev/null +++ b/src/components/ProgressIndicator/CircularProgressIndicator/index.tsx @@ -0,0 +1,2 @@ +export { default } from './CircularProgressIndicator'; +export type { Props } from './CircularProgressIndicator'; diff --git a/src/components/ProgressIndicator/CircularProgressIndicator/tokens.ts b/src/components/ProgressIndicator/CircularProgressIndicator/tokens.ts new file mode 100644 index 0000000000..66c386265c --- /dev/null +++ b/src/components/ProgressIndicator/CircularProgressIndicator/tokens.ts @@ -0,0 +1,6 @@ +export const CircularProgressIndicatorTokens = { + size: 40, + activeThickness: 4, + trackThickness: 4, + trackActiveSpace: 4, +} as const; diff --git a/src/components/ProgressIndicator/CircularWavyProgressIndicator/CircularWavyProgressIndicator.tsx b/src/components/ProgressIndicator/CircularWavyProgressIndicator/CircularWavyProgressIndicator.tsx new file mode 100644 index 0000000000..9857171bcf --- /dev/null +++ b/src/components/ProgressIndicator/CircularWavyProgressIndicator/CircularWavyProgressIndicator.tsx @@ -0,0 +1,305 @@ +import * as React from 'react'; +import { + type ColorValue, + type StyleProp, + View, + type ViewStyle, +} from 'react-native'; + +import Animated, { + Easing, + type SharedValue, + useAnimatedProps, + useAnimatedReaction, + useSharedValue, + withRepeat, + withTiming, + cancelAnimation, +} from 'react-native-reanimated'; +import Svg, { Path } from 'react-native-svg'; + +import { CircularWavyProgressIndicatorTokens as T } from './tokens'; +import { useInternalTheme } from '../../../core/theming'; +import { useReduceMotion } from '../../../theme/accessibility/ReduceMotionContext'; +import { motionEasing } from '../../../theme/tokens/sys/motion'; +import type { ThemeProp } from '../../../types'; +import { + CIRCULAR_INDETERMINATE_DURATION as CYCLE, + circularArc, +} from '../circularIndeterminateSpecs'; +import { useIndeterminatePhase, useProgressAccessibility } from '../hooks'; +import { getProgressIndicatorColors } from '../utils'; +import { buildArcWavePath, NO_DRAW_PATH } from '../wavePath'; + +export type Props = { + /** + * Progress between 0 and 1 (out-of-range values are clamped). Pass a number to set it + * directly, or a Reanimated `SharedValue` to animate it on the UI thread. + */ + progress?: number | SharedValue; + /** + * Show progress of an unknown duration. While set, `progress` is ignored. + */ + indeterminate?: boolean; + /** + * Color of the active indicator. Defaults to `theme.colors.primary`. + */ + color?: ColorValue; + /** + * Color of the track. Defaults to `theme.colors.secondaryContainer`. The track is hidden + * while indeterminate. + */ + trackColor?: ColorValue; + /** + * Indicator diameter. + */ + size?: number; + /** + * Indicator stroke thickness. + */ + thickness?: number; + /** + * Wave height in dp. The wave flattens near the start and end of the progress. + */ + amplitude?: number; + /** + * Distance in dp between two wave peaks. Snapped to fit a whole number of waves around the ring. + */ + wavelength?: number; + /** + * Wave travel speed in dp per second. Defaults to one wavelength per second. + */ + waveSpeed?: number; + style?: StyleProp; + /** + * @optional + */ + theme?: ThemeProp; + /** + * testID to be used on tests. + */ + testID?: string; + /** + * Label describing what is loading, e.g. "Loading news article". + */ + accessibilityLabel?: string; +}; + +const AnimatedPath = Animated.createAnimatedComponent(Path); + +const [STD_X1, STD_Y1, STD_X2, STD_Y2] = motionEasing.standard; +const [ACC_X1, ACC_Y1, ACC_X2, ACC_Y2] = motionEasing.emphasizedAccelerate; + +const AMPLITUDE_DURATION = 500; + +/** + * Material Design 3 wavy circular progress indicator. The active indicator is drawn as a wavy + * arc that flattens near the start and end of the progress. Supports determinate and + * indeterminate modes. + * + * ## Usage + * ```js + * import * as React from 'react'; + * import { CircularWavyProgressIndicator } from 'react-native-paper'; + * + * const MyComponent = () => ( + * + * ); + * + * export default MyComponent; + * ``` + */ +const CircularWavyProgressIndicator = ({ + progress = 0, + indeterminate = false, + color, + trackColor, + size = T.size, + thickness = T.activeThickness, + amplitude, + wavelength, + waveSpeed, + style, + theme: themeOverrides, + testID = 'circular-wavy-progress-indicator', + accessibilityLabel, +}: Props) => { + const theme = useInternalTheme(themeOverrides); + const reduceMotion = useReduceMotion(); + + const { activeColor, trackColor: resolvedTrackColor } = + getProgressIndicatorColors({ theme, color, trackColor }); + + const { progressPercent, busy } = useProgressAccessibility( + progress, + indeterminate + ); + + const center = size / 2; + // Amplitude is constant; the centerline radius is inset by it so the wave peaks always fit + // inside the box. + const maxAmplitude = amplitude ?? T.amplitude; + const radius = (size - thickness) / 2 - maxAmplitude; + const circumference = 2 * Math.PI * radius; + const capAngle = (thickness / 2 / circumference) * 360; + const gapAngle = (T.trackActiveSpace / circumference) * 360; + + const waveLength = wavelength ?? T.wavelength; + // Snap to a whole number of waves so the pattern joins seamlessly around the ring. + const waveCount = Math.max(5, Math.round(circumference / waveLength)); + const waveSpeedValue = waveSpeed ?? waveLength; + + const phase = useIndeterminatePhase(indeterminate, CYCLE); + const waveOffset = useSharedValue(0); + const amp = useSharedValue(0); + + // Slide the wave pattern at a steady speed; one wavelength per `duration`. + React.useEffect(() => { + if (reduceMotion) { + cancelAnimation(waveOffset); + waveOffset.value = 0; + return; + } + const duration = Math.max(50, (waveLength / waveSpeedValue) * 1000); + waveOffset.value = 0; + waveOffset.value = withRepeat( + withTiming(1, { duration, easing: Easing.linear }), + -1, + false + ); + return () => cancelAnimation(waveOffset); + }, [reduceMotion, waveLength, waveSpeedValue, waveOffset]); + + // Grow the wave once progress is underway and flatten it near the ends. + useAnimatedReaction( + () => { + if (reduceMotion) return 0; + if (indeterminate) return 1; + const p = Math.min( + 1, + Math.max(0, typeof progress === 'number' ? progress : progress.value) + ); + return p > 0.1 && p < 0.95 ? 1 : 0; + }, + (on, previous) => { + if (on === previous) return; + amp.value = withTiming(on ? maxAmplitude : 0, { + duration: AMPLITUDE_DURATION, + easing: on + ? Easing.bezier(STD_X1, STD_Y1, STD_X2, STD_Y2) + : Easing.bezier(ACC_X1, ACC_Y1, ACC_X2, ACC_Y2), + }); + }, + [reduceMotion, indeterminate, progress, maxAmplitude] + ); + + const activeProps = useAnimatedProps(() => { + const wavePhase = waveOffset.value * 2 * Math.PI; + if (indeterminate) { + const { startAngle, sweepAngle } = circularArc(phase.value * CYCLE); + return { + d: buildArcWavePath( + center, + center, + radius, + startAngle, + sweepAngle, + amp.value, + waveCount, + wavePhase + ), + }; + } + const p = Math.min( + 1, + Math.max(0, typeof progress === 'number' ? progress : progress.value) + ); + return { + d: buildArcWavePath( + center, + center, + radius, + -90, + p * 360, + amp.value, + waveCount, + wavePhase + ), + }; + }, [indeterminate, progress, center, radius, waveCount]); + + // The track is the arc left by the active indicator: it starts a gap past the active head and + // runs back to a gap before its tail, with rounded ends. It follows the active arc in both + // determinate and indeterminate modes, so it shrinks as the active grows. + const trackProps = useAnimatedProps(() => { + let activeStart; + let activeSweep; + if (indeterminate) { + const { startAngle, sweepAngle } = circularArc(phase.value * CYCLE); + activeStart = startAngle; + activeSweep = sweepAngle; + } else { + const p = Math.min( + 1, + Math.max(0, typeof progress === 'number' ? progress : progress.value) + ); + activeStart = -90; + activeSweep = p * 360; + } + if (activeSweep <= 0) { + return { + d: buildArcWavePath(center, center, radius, -90, 360, 0, waveCount, 0), + }; + } + const trackSweep = 360 - activeSweep - 4 * capAngle - 2 * gapAngle; + if (trackSweep <= 0) return { d: NO_DRAW_PATH }; + const trackStart = activeStart + activeSweep + 2 * capAngle + gapAngle; + return { + d: buildArcWavePath( + center, + center, + radius, + trackStart, + trackSweep, + 0, + waveCount, + 0 + ), + }; + }, [indeterminate, progress, center, radius, capAngle, gapAngle, waveCount]); + + return ( + + + + + + + ); +}; + +export default CircularWavyProgressIndicator; diff --git a/src/components/ProgressIndicator/CircularWavyProgressIndicator/__tests__/CircularWavyProgressIndicator.test.tsx b/src/components/ProgressIndicator/CircularWavyProgressIndicator/__tests__/CircularWavyProgressIndicator.test.tsx new file mode 100644 index 0000000000..70f9597701 --- /dev/null +++ b/src/components/ProgressIndicator/CircularWavyProgressIndicator/__tests__/CircularWavyProgressIndicator.test.tsx @@ -0,0 +1,81 @@ +import * as React from 'react'; + +import { render } from '../../../../test-utils'; +import CircularWavyProgressIndicator from '../CircularWavyProgressIndicator'; + +const a11yRole = 'progressbar'; + +// One smoke snapshot; behavior is checked with the assertions below. +it('renders determinate progress', () => { + const tree = render( + + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('resolves active and track colors', () => { + const tree = render( + + ); + + expect(tree.getByTestId('pi-active').props.stroke).toBe('red'); + expect(tree.getByTestId('pi-track').props.stroke).toBe('blue'); +}); + +it('renders the active stroke and a track when indeterminate', () => { + const tree = render( + + ); + + expect(tree.getByTestId('pi-active')).toBeTruthy(); + expect(tree.getByTestId('pi-track')).toBeTruthy(); + expect(tree.getByRole(a11yRole).props.accessibilityValue).toEqual({}); +}); + +it('reports the determinate value', () => { + const tree = render(); + + expect(tree.getByRole(a11yRole).props.accessibilityValue).toEqual({ + min: 0, + max: 100, + now: 42, + }); +}); + +it('is busy below 100% and not busy at 100% (determinate)', () => { + const partial = render(); + expect(partial.getByRole(a11yRole).props.accessibilityState).toEqual({ + busy: true, + }); + + const complete = render(); + expect(complete.getByRole(a11yRole).props.accessibilityState).toEqual({ + busy: false, + }); +}); + +it('is busy when indeterminate', () => { + const tree = render(); + + expect(tree.getByRole(a11yRole).props.accessibilityState).toEqual({ + busy: true, + }); +}); + +it('keeps the track when toggling indeterminate -> determinate', () => { + const tree = render( + + ); + expect(tree.getByTestId('pi-track')).toBeTruthy(); + + tree.rerender(); + + expect(tree.getByTestId('pi-track')).toBeTruthy(); + expect(tree.getByTestId('pi-active')).toBeTruthy(); +}); diff --git a/src/components/ProgressIndicator/CircularWavyProgressIndicator/__tests__/__snapshots__/CircularWavyProgressIndicator.test.tsx.snap b/src/components/ProgressIndicator/CircularWavyProgressIndicator/__tests__/__snapshots__/CircularWavyProgressIndicator.test.tsx.snap new file mode 100644 index 0000000000..c03187e73d --- /dev/null +++ b/src/components/ProgressIndicator/CircularWavyProgressIndicator/__tests__/__snapshots__/CircularWavyProgressIndicator.test.tsx.snap @@ -0,0 +1,60 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders determinate progress 1`] = ` + + + + + + +`; diff --git a/src/components/ProgressIndicator/CircularWavyProgressIndicator/index.tsx b/src/components/ProgressIndicator/CircularWavyProgressIndicator/index.tsx new file mode 100644 index 0000000000..edc7b18526 --- /dev/null +++ b/src/components/ProgressIndicator/CircularWavyProgressIndicator/index.tsx @@ -0,0 +1,2 @@ +export { default } from './CircularWavyProgressIndicator'; +export type { Props } from './CircularWavyProgressIndicator'; diff --git a/src/components/ProgressIndicator/CircularWavyProgressIndicator/tokens.ts b/src/components/ProgressIndicator/CircularWavyProgressIndicator/tokens.ts new file mode 100644 index 0000000000..83248409ae --- /dev/null +++ b/src/components/ProgressIndicator/CircularWavyProgressIndicator/tokens.ts @@ -0,0 +1,8 @@ +export const CircularWavyProgressIndicatorTokens = { + size: 48, + activeThickness: 4, + trackThickness: 4, + trackActiveSpace: 4, + amplitude: 1.6, + wavelength: 15, +} as const; diff --git a/src/components/ProgressIndicator/LinearProgressIndicator/LinearProgressIndicator.tsx b/src/components/ProgressIndicator/LinearProgressIndicator/LinearProgressIndicator.tsx new file mode 100644 index 0000000000..4e6422e520 --- /dev/null +++ b/src/components/ProgressIndicator/LinearProgressIndicator/LinearProgressIndicator.tsx @@ -0,0 +1,355 @@ +import * as React from 'react'; +import { + type ColorValue, + type LayoutChangeEvent, + Platform, + type StyleProp, + StyleSheet, + View, + type ViewStyle, +} from 'react-native'; + +import Animated, { + type SharedValue, + useAnimatedStyle, +} from 'react-native-reanimated'; + +import { LinearProgressIndicatorTokens as T } from './tokens'; +import { useLocale } from '../../../core/locale'; +import { useInternalTheme } from '../../../core/theming'; +import type { ThemeProp } from '../../../types'; +import { useIndeterminatePhase, useProgressAccessibility } from '../hooks'; +import { + LINEAR_INDETERMINATE_DURATION as CYCLE, + LINEAR_INDETERMINATE_LINES as LINES, + lineFraction, +} from '../indeterminateSpecs'; +import { getProgressIndicatorColors } from '../utils'; + +export type Props = { + /** + * Progress between 0 and 1 (out-of-range values are clamped). Pass a number to set it + * directly, or a Reanimated `SharedValue` to animate it on the UI thread. + */ + progress?: number | SharedValue; + /** + * Show progress of an unknown duration. While set, `progress` is ignored. + */ + indeterminate?: boolean; + /** + * Color of the active indicator and the stop dot. Defaults to `theme.colors.primary`. + */ + color?: ColorValue; + /** + * Color of the track. Defaults to `theme.colors.secondaryContainer`. + */ + trackColor?: ColorValue; + thickness?: number; + style?: StyleProp; + /** + * @optional + */ + theme?: ThemeProp; + /** + * testID to be used on tests. + */ + testID?: string; + /** + * Label describing what is loading, e.g. "Loading news article". + */ + accessibilityLabel?: string; +}; + +/** + * Material Design 3 linear progress indicator. Supports determinate and indeterminate modes. + * + * ## Usage + * ```js + * import * as React from 'react'; + * import { LinearProgressIndicator } from 'react-native-paper'; + * + * const MyComponent = () => ( + * + * ); + * + * export default MyComponent; + * ``` + */ +const LinearProgressIndicator = ({ + progress = 0, + indeterminate = false, + color, + trackColor, + thickness = T.activeThickness, + style, + theme: themeOverrides, + testID = 'linear-progress-indicator', + accessibilityLabel, +}: Props) => { + const theme = useInternalTheme(themeOverrides); + const { direction } = useLocale(); + + const xSign = direction === 'ltr' ? 1 : -1; + const leadingAnchor: ViewStyle = + Platform.OS === 'web' && direction === 'rtl' ? { right: 0 } : { left: 0 }; + + const { + activeColor, + trackColor: resolvedTrackColor, + stopColor, + } = getProgressIndicatorColors({ theme, color, trackColor }); + + const [width, setWidth] = React.useState(0); + + const { progressPercent, busy } = useProgressAccessibility( + progress, + indeterminate + ); + + const radius = thickness / 2; + const stopSize = Math.min(thickness, T.stopSize); + const stopOffset = Math.min( + (thickness - stopSize) / 2, + T.stopTrailingSpaceMax + ); + const gap = T.trackActiveSpace; + + const phase = useIndeterminatePhase(indeterminate, CYCLE); + + const onLayout = (event: LayoutChangeEvent) => { + setWidth(event.nativeEvent.layout.width); + }; + + const activeDeterminateStyle = useAnimatedStyle(() => { + const p = Math.min( + 1, + Math.max(0, typeof progress === 'number' ? progress : progress.value) + ); + const w = p <= 0 ? 0 : Math.max(p * width, thickness); + return { width: w }; + }, [progress, width, thickness]); + + const trackDeterminateStyle = useAnimatedStyle(() => { + const p = Math.min( + 1, + Math.max(0, typeof progress === 'number' ? progress : progress.value) + ); + const head = p * width; + const activeWidth = p <= 0 ? 0 : Math.max(head, thickness); + const gapPx = activeWidth > 0 ? Math.min(gap, head) : 0; + const offset = Math.min(activeWidth + gapPx, width); + return { + transform: [{ translateX: xSign * offset }], + width: Math.max(0, width - offset), + }; + }, [progress, width, thickness, gap, xSign]); + + const segment1Style = useAnimatedStyle(() => { + const elapsed = phase.value * CYCLE; + const tail = + lineFraction(elapsed, LINES.firstTail.delay, LINES.firstTail.duration) * + width; + const head = + lineFraction(elapsed, LINES.firstHead.delay, LINES.firstHead.duration) * + width; + return { + transform: [{ translateX: xSign * tail }], + width: Math.max(0, head - tail), + }; + }, [width, xSign]); + + const segment2Style = useAnimatedStyle(() => { + const elapsed = phase.value * CYCLE; + const tail = + lineFraction(elapsed, LINES.secondTail.delay, LINES.secondTail.duration) * + width; + const head = + lineFraction(elapsed, LINES.secondHead.delay, LINES.secondHead.duration) * + width; + return { + transform: [{ translateX: xSign * tail }], + width: Math.max(0, head - tail), + }; + }, [width, xSign]); + + const trackRightStyle = useAnimatedStyle(() => { + const elapsed = phase.value * CYCLE; + const firstHead = + lineFraction(elapsed, LINES.firstHead.delay, LINES.firstHead.duration) * + width; + const offset = firstHead > 0 ? firstHead + gap : 0; + return { + transform: [{ translateX: xSign * offset }], + width: Math.max(0, width - offset), + }; + }, [width, gap, xSign]); + + const trackMiddleStyle = useAnimatedStyle(() => { + const elapsed = phase.value * CYCLE; + const secondHead = + lineFraction(elapsed, LINES.secondHead.delay, LINES.secondHead.duration) * + width; + const firstTail = + lineFraction(elapsed, LINES.firstTail.delay, LINES.firstTail.duration) * + width; + const offset = secondHead > 0 ? secondHead + gap : 0; + const end = firstTail < width ? firstTail - gap : width; + return { + transform: [{ translateX: xSign * offset }], + width: Math.max(0, end - offset), + }; + }, [width, gap, xSign]); + + const trackLeftStyle = useAnimatedStyle(() => { + const elapsed = phase.value * CYCLE; + const secondTail = + lineFraction(elapsed, LINES.secondTail.delay, LINES.secondTail.duration) * + width; + return { width: Math.max(0, secondTail - gap) }; + }, [width, gap]); + + const lineStyle = { height: thickness, borderRadius: radius }; + + let body: React.ReactNode; + if (!indeterminate) { + body = ( + <> + + + {width > 0 ? ( + + ) : null} + + ); + } else { + body = ( + <> + + + + + + + ); + } + + return ( + + {body} + + ); +}; + +const styles = StyleSheet.create({ + container: { + width: '100%', + justifyContent: 'center', + overflow: 'visible', + }, + inner: { + flex: 1, + justifyContent: 'center', + }, + positioned: { + position: 'absolute', + }, + stop: { + position: 'absolute', + }, +}); + +export default LinearProgressIndicator; diff --git a/src/components/ProgressIndicator/LinearProgressIndicator/__tests__/LinearProgressIndicator.test.tsx b/src/components/ProgressIndicator/LinearProgressIndicator/__tests__/LinearProgressIndicator.test.tsx new file mode 100644 index 0000000000..4cfc6521e8 --- /dev/null +++ b/src/components/ProgressIndicator/LinearProgressIndicator/__tests__/LinearProgressIndicator.test.tsx @@ -0,0 +1,115 @@ +import * as React from 'react'; + +import { act } from '@testing-library/react-native'; + +import { render } from '../../../../test-utils'; +import LinearProgressIndicator from '../LinearProgressIndicator'; + +const a11yRole = 'progressbar'; + +const layoutEvent = { + nativeEvent: { layout: { width: 100 } }, +}; + +const triggerLayout = async ( + tree: ReturnType +): Promise => { + await act(async () => { + tree.getByRole(a11yRole).props.onLayout(layoutEvent); + }); +}; + +// One smoke snapshot; behavior is checked with the assertions below. +it('renders determinate progress', async () => { + const tree = render(); + await triggerLayout(tree); + + expect(tree.toJSON()).toMatchSnapshot(); +}); + +it('resolves active, track and stop colors', async () => { + const tree = render( + + ); + await triggerLayout(tree); + + expect(tree.getByTestId('pi-active')).toHaveStyle({ backgroundColor: 'red' }); + expect(tree.getByTestId('pi-track')).toHaveStyle({ backgroundColor: 'blue' }); + expect(tree.getByTestId('pi-stop')).toHaveStyle({ backgroundColor: 'red' }); +}); + +it('lays out the determinate active, track and stop dot', async () => { + const tree = render(); + await triggerLayout(tree); + + // width 100, progress 0.5: active 50, then a 4dp gap, then the track. Segments anchor at the + // leading edge and shift with translateX (flipped for RTL). + expect(tree.getByTestId('pi-active')).toHaveStyle({ left: 0, width: 50 }); + expect(tree.getByTestId('pi-track')).toHaveStyle({ + transform: [{ translateX: 54 }], + width: 46, + }); + expect(tree.getByTestId('pi-stop')).toHaveStyle({ right: 0 }); +}); + +it('renders separate track segments and no stop dot when indeterminate', async () => { + const tree = render(); + await triggerLayout(tree); + + expect(tree.getByTestId('pi-track')).toBeTruthy(); + expect(tree.getByTestId('pi-active')).toBeTruthy(); + expect(tree.queryByTestId('pi-stop')).toBeNull(); + expect(tree.getByRole(a11yRole).props.accessibilityValue).toEqual({}); +}); + +it('reports the determinate value', async () => { + const tree = render(); + await triggerLayout(tree); + + expect(tree.getByRole(a11yRole).props.accessibilityValue).toEqual({ + min: 0, + max: 100, + now: 42, + }); +}); + +it('is busy below 100% and not busy at 100% (determinate)', async () => { + const partial = render(); + await triggerLayout(partial); + expect(partial.getByRole(a11yRole).props.accessibilityState).toEqual({ + busy: true, + }); + + const complete = render(); + await triggerLayout(complete); + expect(complete.getByRole(a11yRole).props.accessibilityState).toEqual({ + busy: false, + }); +}); + +it('is busy when indeterminate', async () => { + const tree = render(); + await triggerLayout(tree); + + expect(tree.getByRole(a11yRole).props.accessibilityState).toEqual({ + busy: true, + }); +}); + +it('resets the active indicator layout when toggling indeterminate -> determinate', async () => { + const tree = render(); + await triggerLayout(tree); + expect(tree.queryByTestId('pi-stop')).toBeNull(); + + tree.rerender(); + await triggerLayout(tree); + + // Active must start at the leading edge after the toggle. + expect(tree.getByTestId('pi-active')).toHaveStyle({ left: 0, width: 30 }); + expect(tree.getByTestId('pi-stop')).toBeTruthy(); +}); diff --git a/src/components/ProgressIndicator/LinearProgressIndicator/__tests__/__snapshots__/LinearProgressIndicator.test.tsx.snap b/src/components/ProgressIndicator/LinearProgressIndicator/__tests__/__snapshots__/LinearProgressIndicator.test.tsx.snap new file mode 100644 index 0000000000..76f8358653 --- /dev/null +++ b/src/components/ProgressIndicator/LinearProgressIndicator/__tests__/__snapshots__/LinearProgressIndicator.test.tsx.snap @@ -0,0 +1,116 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders determinate progress 1`] = ` + + + + + + + +`; diff --git a/src/components/ProgressIndicator/LinearProgressIndicator/index.tsx b/src/components/ProgressIndicator/LinearProgressIndicator/index.tsx new file mode 100644 index 0000000000..41f4853923 --- /dev/null +++ b/src/components/ProgressIndicator/LinearProgressIndicator/index.tsx @@ -0,0 +1,2 @@ +export { default } from './LinearProgressIndicator'; +export type { Props } from './LinearProgressIndicator'; diff --git a/src/components/ProgressIndicator/LinearProgressIndicator/tokens.ts b/src/components/ProgressIndicator/LinearProgressIndicator/tokens.ts new file mode 100644 index 0000000000..39df2c49c0 --- /dev/null +++ b/src/components/ProgressIndicator/LinearProgressIndicator/tokens.ts @@ -0,0 +1,8 @@ +export const LinearProgressIndicatorTokens = { + height: 4, + activeThickness: 4, + trackThickness: 4, + stopSize: 4, + trackActiveSpace: 4, + stopTrailingSpaceMax: 6, +} as const; diff --git a/src/components/ProgressIndicator/LinearWavyProgressIndicator/LinearWavyProgressIndicator.tsx b/src/components/ProgressIndicator/LinearWavyProgressIndicator/LinearWavyProgressIndicator.tsx new file mode 100644 index 0000000000..218264f196 --- /dev/null +++ b/src/components/ProgressIndicator/LinearWavyProgressIndicator/LinearWavyProgressIndicator.tsx @@ -0,0 +1,390 @@ +import * as React from 'react'; +import { + type ColorValue, + type LayoutChangeEvent, + StyleSheet, + View, + type StyleProp, + type ViewStyle, +} from 'react-native'; + +import Animated, { + Easing, + type SharedValue, + useAnimatedProps, + useAnimatedReaction, + useSharedValue, + withRepeat, + withTiming, + cancelAnimation, +} from 'react-native-reanimated'; +import Svg, { Circle, G, Path } from 'react-native-svg'; + +import { LinearWavyProgressIndicatorTokens as T } from './tokens'; +import { useLocale } from '../../../core/locale'; +import { useInternalTheme } from '../../../core/theming'; +import { useReduceMotion } from '../../../theme/accessibility/ReduceMotionContext'; +import { motionEasing } from '../../../theme/tokens/sys/motion'; +import type { ThemeProp } from '../../../types'; +import { useIndeterminatePhase, useProgressAccessibility } from '../hooks'; +import { + LINEAR_INDETERMINATE_DURATION as CYCLE, + LINEAR_INDETERMINATE_LINES as LINES, + lineFraction, +} from '../indeterminateSpecs'; +import { getProgressIndicatorColors } from '../utils'; +import { buildLinearWavePath, buildStraightPath } from '../wavePath'; + +export type Props = { + /** + * Progress between 0 and 1 (out-of-range values are clamped). Pass a number to set it + * directly, or a Reanimated `SharedValue` to animate it on the UI thread. + */ + progress?: number | SharedValue; + /** + * Show progress of an unknown duration. While set, `progress` is ignored. + */ + indeterminate?: boolean; + /** + * Color of the active indicator and the stop dot. Defaults to `theme.colors.primary`. + */ + color?: ColorValue; + /** + * Color of the track. Defaults to `theme.colors.secondaryContainer`. + */ + trackColor?: ColorValue; + /** + * Stroke thickness of the active indicator and the track. + */ + thickness?: number; + /** + * Wave height in dp. The wave flattens near the start and end of the progress. + */ + amplitude?: number; + /** + * Distance in dp between two wave peaks. Defaults to 40 (determinate) or 20 (indeterminate). + */ + wavelength?: number; + /** + * Wave travel speed in dp per second. Defaults to one wavelength per second. + */ + waveSpeed?: number; + style?: StyleProp; + /** + * @optional + */ + theme?: ThemeProp; + /** + * testID to be used on tests. + */ + testID?: string; + /** + * Label describing what is loading, e.g. "Loading news article". + */ + accessibilityLabel?: string; +}; + +const AnimatedPath = Animated.createAnimatedComponent(Path); +const AnimatedCircle = Animated.createAnimatedComponent(Circle); + +const [STD_X1, STD_Y1, STD_X2, STD_Y2] = motionEasing.standard; +const [ACC_X1, ACC_Y1, ACC_X2, ACC_Y2] = motionEasing.emphasizedAccelerate; + +const AMPLITUDE_DURATION = 500; + +/** + * Material Design 3 wavy linear progress indicator. The active indicator is drawn as a moving + * sine wave that flattens near the start and end of the progress. Supports determinate and + * indeterminate modes. + * + * ## Usage + * ```js + * import * as React from 'react'; + * import { LinearWavyProgressIndicator } from 'react-native-paper'; + * + * const MyComponent = () => ( + * + * ); + * + * export default MyComponent; + * ``` + */ +const LinearWavyProgressIndicator = ({ + progress = 0, + indeterminate = false, + color, + trackColor, + thickness = T.activeThickness, + amplitude, + wavelength, + waveSpeed, + style, + theme: themeOverrides, + testID = 'linear-wavy-progress-indicator', + accessibilityLabel, +}: Props) => { + const theme = useInternalTheme(themeOverrides); + const { direction } = useLocale(); + const reduceMotion = useReduceMotion(); + + const { + activeColor, + trackColor: resolvedTrackColor, + stopColor, + } = getProgressIndicatorColors({ theme, color, trackColor }); + + const [width, setWidth] = React.useState(0); + + const { progressPercent, busy } = useProgressAccessibility( + progress, + indeterminate + ); + + const capWidth = thickness / 2; + // Amplitude is constant; the container grows so the wave plus stroke always fits + // (height = thickness + 2 * amplitude), matching the MD3 spec. + const maxAmplitude = amplitude ?? T.amplitude; + const containerHeight = Math.max(T.height, thickness + 2 * maxAmplitude); + const centerY = containerHeight / 2; + const gap = T.trackActiveSpace; + const stopSize = Math.min(thickness, T.stopSize); + + const waveLength = + wavelength ?? + (indeterminate ? T.wavelengthIndeterminate : T.wavelengthDeterminate); + const waveSpeedValue = waveSpeed ?? waveLength; + + const phase = useIndeterminatePhase(indeterminate, CYCLE); + const waveOffset = useSharedValue(0); + const amp = useSharedValue(0); + + // Slide the wave pattern at a steady speed; one wavelength per `duration`. + React.useEffect(() => { + if (reduceMotion) { + cancelAnimation(waveOffset); + waveOffset.value = 0; + return; + } + const duration = Math.max(50, (waveLength / waveSpeedValue) * 1000); + waveOffset.value = 0; + waveOffset.value = withRepeat( + withTiming(1, { duration, easing: Easing.linear }), + -1, + false + ); + return () => cancelAnimation(waveOffset); + }, [reduceMotion, waveLength, waveSpeedValue, waveOffset]); + + // Grow the wave once progress is underway and flatten it near the ends. + useAnimatedReaction( + () => { + if (reduceMotion) return 0; + if (indeterminate) return 1; + const p = Math.min( + 1, + Math.max(0, typeof progress === 'number' ? progress : progress.value) + ); + return p > 0.1 && p < 0.95 ? 1 : 0; + }, + (on, previous) => { + if (on === previous) return; + amp.value = withTiming(on ? maxAmplitude : 0, { + duration: AMPLITUDE_DURATION, + easing: on + ? Easing.bezier(STD_X1, STD_Y1, STD_X2, STD_Y2) + : Easing.bezier(ACC_X1, ACC_Y1, ACC_X2, ACC_Y2), + }); + }, + [reduceMotion, indeterminate, progress, maxAmplitude] + ); + + const onLayout = (event: LayoutChangeEvent) => { + setWidth(event.nativeEvent.layout.width); + }; + + const activeProps = useAnimatedProps(() => { + const shift = waveOffset.value * waveLength; + if (indeterminate) { + const elapsed = phase.value * CYCLE; + const min = capWidth; + const max = width - capWidth; + const clip = (value: number) => Math.min(max, Math.max(min, value)); + const s1Tail = clip( + lineFraction(elapsed, LINES.firstTail.delay, LINES.firstTail.duration) * + width + ); + const s1Head = clip( + lineFraction(elapsed, LINES.firstHead.delay, LINES.firstHead.duration) * + width + ); + const s2Tail = clip( + lineFraction( + elapsed, + LINES.secondTail.delay, + LINES.secondTail.duration + ) * width + ); + const s2Head = clip( + lineFraction( + elapsed, + LINES.secondHead.delay, + LINES.secondHead.duration + ) * width + ); + return { + d: + buildLinearWavePath( + s1Tail, + s1Head, + centerY, + amp.value, + waveLength, + shift + ) + + buildLinearWavePath( + s2Tail, + s2Head, + centerY, + amp.value, + waveLength, + shift + ), + }; + } + const p = Math.min( + 1, + Math.max(0, typeof progress === 'number' ? progress : progress.value) + ); + const head = capWidth + p * (width - 2 * capWidth); + return { + d: buildLinearWavePath( + capWidth, + head, + centerY, + amp.value, + waveLength, + shift + ), + }; + }, [indeterminate, progress, width, centerY, capWidth, waveLength]); + + const trackProps = useAnimatedProps(() => { + const min = capWidth; + const max = width - capWidth; + const clip = (value: number) => Math.min(max, Math.max(min, value)); + if (indeterminate) { + const elapsed = phase.value * CYCLE; + const firstHead = + lineFraction(elapsed, LINES.firstHead.delay, LINES.firstHead.duration) * + width; + const firstTail = + lineFraction(elapsed, LINES.firstTail.delay, LINES.firstTail.duration) * + width; + const secondHead = + lineFraction( + elapsed, + LINES.secondHead.delay, + LINES.secondHead.duration + ) * width; + const secondTail = + lineFraction( + elapsed, + LINES.secondTail.delay, + LINES.secondTail.duration + ) * width; + const right = buildStraightPath( + clip(firstHead > 0 ? firstHead + gap : min), + max, + centerY + ); + const middle = buildStraightPath( + clip(secondHead > 0 ? secondHead + gap : min), + clip(firstTail < width ? firstTail - gap : max), + centerY + ); + const left = buildStraightPath(min, clip(secondTail - gap), centerY); + return { d: left + middle + right }; + } + const p = Math.min( + 1, + Math.max(0, typeof progress === 'number' ? progress : progress.value) + ); + if (p <= 0) return { d: buildStraightPath(min, max, centerY) }; + const head = capWidth + p * (width - 2 * capWidth); + const trackStart = head + gap + 2 * capWidth; + return { d: buildStraightPath(clip(trackStart), max, centerY) }; + }, [indeterminate, progress, width, centerY, capWidth, gap]); + + const stopProps = useAnimatedProps(() => { + const p = Math.min( + 1, + Math.max(0, typeof progress === 'number' ? progress : progress.value) + ); + const head = capWidth + p * (width - 2 * capWidth); + const stopCenter = width - capWidth; + const overlap = Math.max(0, head + capWidth - (stopCenter - stopSize / 2)); + return { r: Math.max(0, stopSize / 2 - overlap) }; + }, [progress, width, capWidth, stopSize]); + + const mirror = + direction === 'rtl' && width > 0 + ? `translate(${width}, 0) scale(-1, 1)` + : undefined; + + return ( + + {width > 0 ? ( + + + + + {!indeterminate ? ( + + ) : null} + + + ) : null} + + ); +}; + +const styles = StyleSheet.create({ + container: { + width: '100%', + justifyContent: 'center', + overflow: 'visible', + }, +}); + +export default LinearWavyProgressIndicator; diff --git a/src/components/ProgressIndicator/LinearWavyProgressIndicator/__tests__/LinearWavyProgressIndicator.test.tsx b/src/components/ProgressIndicator/LinearWavyProgressIndicator/__tests__/LinearWavyProgressIndicator.test.tsx new file mode 100644 index 0000000000..0fbd7b9c02 --- /dev/null +++ b/src/components/ProgressIndicator/LinearWavyProgressIndicator/__tests__/LinearWavyProgressIndicator.test.tsx @@ -0,0 +1,102 @@ +import * as React from 'react'; + +import { act } from '@testing-library/react-native'; + +import { render } from '../../../../test-utils'; +import LinearWavyProgressIndicator from '../LinearWavyProgressIndicator'; + +const a11yRole = 'progressbar'; + +const layoutEvent = { + nativeEvent: { layout: { width: 100 } }, +}; + +const triggerLayout = async ( + tree: ReturnType +): Promise => { + await act(async () => { + tree.getByRole(a11yRole).props.onLayout(layoutEvent); + }); +}; + +it('renders determinate progress', async () => { + const tree = render(); + await triggerLayout(tree); + + expect(tree.toJSON()).toMatchSnapshot(); +}); + +it('resolves active, track and stop colors', async () => { + const tree = render( + + ); + await triggerLayout(tree); + + expect(tree.getByTestId('pi-active').props.stroke).toBe('red'); + expect(tree.getByTestId('pi-track').props.stroke).toBe('blue'); + expect(tree.getByTestId('pi-stop').props.fill).toBe('red'); +}); + +it('renders the active and track strokes and no stop dot when indeterminate', async () => { + const tree = render( + + ); + await triggerLayout(tree); + + expect(tree.getByTestId('pi-track')).toBeTruthy(); + expect(tree.getByTestId('pi-active')).toBeTruthy(); + expect(tree.queryByTestId('pi-stop')).toBeNull(); + expect(tree.getByRole(a11yRole).props.accessibilityValue).toEqual({}); +}); + +it('reports the determinate value', async () => { + const tree = render(); + await triggerLayout(tree); + + expect(tree.getByRole(a11yRole).props.accessibilityValue).toEqual({ + min: 0, + max: 100, + now: 42, + }); +}); + +it('is busy below 100% and not busy at 100% (determinate)', async () => { + const partial = render(); + await triggerLayout(partial); + expect(partial.getByRole(a11yRole).props.accessibilityState).toEqual({ + busy: true, + }); + + const complete = render(); + await triggerLayout(complete); + expect(complete.getByRole(a11yRole).props.accessibilityState).toEqual({ + busy: false, + }); +}); + +it('is busy when indeterminate', async () => { + const tree = render(); + await triggerLayout(tree); + + expect(tree.getByRole(a11yRole).props.accessibilityState).toEqual({ + busy: true, + }); +}); + +it('shows the stop dot again when toggling indeterminate -> determinate', async () => { + const tree = render( + + ); + await triggerLayout(tree); + expect(tree.queryByTestId('pi-stop')).toBeNull(); + + tree.rerender(); + await triggerLayout(tree); + + expect(tree.getByTestId('pi-stop')).toBeTruthy(); +}); diff --git a/src/components/ProgressIndicator/LinearWavyProgressIndicator/__tests__/__snapshots__/LinearWavyProgressIndicator.test.tsx.snap b/src/components/ProgressIndicator/LinearWavyProgressIndicator/__tests__/__snapshots__/LinearWavyProgressIndicator.test.tsx.snap new file mode 100644 index 0000000000..abf82c58a2 --- /dev/null +++ b/src/components/ProgressIndicator/LinearWavyProgressIndicator/__tests__/__snapshots__/LinearWavyProgressIndicator.test.tsx.snap @@ -0,0 +1,78 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders determinate progress 1`] = ` + + + + + + + + + +`; diff --git a/src/components/ProgressIndicator/LinearWavyProgressIndicator/index.tsx b/src/components/ProgressIndicator/LinearWavyProgressIndicator/index.tsx new file mode 100644 index 0000000000..73e705f283 --- /dev/null +++ b/src/components/ProgressIndicator/LinearWavyProgressIndicator/index.tsx @@ -0,0 +1,2 @@ +export { default } from './LinearWavyProgressIndicator'; +export type { Props } from './LinearWavyProgressIndicator'; diff --git a/src/components/ProgressIndicator/LinearWavyProgressIndicator/tokens.ts b/src/components/ProgressIndicator/LinearWavyProgressIndicator/tokens.ts new file mode 100644 index 0000000000..5c196f7db5 --- /dev/null +++ b/src/components/ProgressIndicator/LinearWavyProgressIndicator/tokens.ts @@ -0,0 +1,10 @@ +export const LinearWavyProgressIndicatorTokens = { + height: 10, + activeThickness: 4, + trackThickness: 4, + stopSize: 4, + trackActiveSpace: 4, + amplitude: 3, + wavelengthDeterminate: 40, + wavelengthIndeterminate: 20, +} as const; diff --git a/src/components/ProgressIndicator/circularIndeterminateSpecs.ts b/src/components/ProgressIndicator/circularIndeterminateSpecs.ts new file mode 100644 index 0000000000..edd905a791 --- /dev/null +++ b/src/components/ProgressIndicator/circularIndeterminateSpecs.ts @@ -0,0 +1,72 @@ +/** + * Timing for the indeterminate circular spinner. Three motions share one 6000ms clock: + * - global rotation: linear 0 -> 1080deg, + * - extra rotation: four 90deg steps (each a 300ms move, then a pause), 0 -> 360deg, + * - sweep length: grows 0.1 -> 0.87 of a turn, then shrinks back. + * Total rotation per clock is 1080 + 360 = 1440deg (four full turns), so the loop has no visible + * seam. Everything reads the same `elapsed`, so the spin and the grow/shrink stay in sync. + */ +import { Easing } from 'react-native-reanimated'; + +import { motionEasing } from '../../theme/tokens/sys/motion'; + +export const CIRCULAR_INDETERMINATE_DURATION = 6000; + +const GLOBAL_ROTATION_TARGET = 1080; +const ADDITIONAL_ROTATION_TARGET = 360; + +const STEP_MOVE_DURATION = 300; +const STEP_INTERVAL = 1500; +const STEP_DEGREES = 90; +const STEP_COUNT = ADDITIONAL_ROTATION_TARGET / STEP_DEGREES; // 4 + +const MIN_PROGRESS = 0.1; +const MAX_PROGRESS = 0.87; + +const [STD_X1, STD_Y1, STD_X2, STD_Y2] = motionEasing.standard; + +const clamp01 = (value: number) => { + 'worklet'; + return Math.min(1, Math.max(0, value)); +}; + +const easeStandard = (x: number) => { + 'worklet'; + return Easing.bezierFn(STD_X1, STD_Y1, STD_X2, STD_Y2)(x); +}; + +const globalRotation = (elapsed: number) => { + 'worklet'; + return (elapsed / CIRCULAR_INDETERMINATE_DURATION) * GLOBAL_ROTATION_TARGET; +}; + +const additionalRotation = (elapsed: number) => { + 'worklet'; + const step = Math.min(Math.floor(elapsed / STEP_INTERVAL), STEP_COUNT - 1); + const local = elapsed - step * STEP_INTERVAL; + const moved = clamp01(local / STEP_MOVE_DURATION); + return (step + moved) * STEP_DEGREES; +}; + +const progressFraction = (elapsed: number) => { + 'worklet'; + const half = CIRCULAR_INDETERMINATE_DURATION / 2; + const span = MAX_PROGRESS - MIN_PROGRESS; + if (elapsed <= half) { + return MIN_PROGRESS + span * (elapsed / half); + } + return MAX_PROGRESS - span * easeStandard((elapsed - half) / half); +}; + +/** + * Arc shape at `elapsed` ms into the clock. Returns the start angle (degrees, clockwise) and the + * visible sweep length in degrees. The arc spins, so its start angle is just the rotation. + */ +export function circularArc(elapsed: number) { + 'worklet'; + const t = elapsed % CIRCULAR_INDETERMINATE_DURATION; + return { + startAngle: globalRotation(t) + additionalRotation(t), + sweepAngle: progressFraction(t) * 360, + }; +} diff --git a/src/components/ProgressIndicator/hooks.ts b/src/components/ProgressIndicator/hooks.ts new file mode 100644 index 0000000000..7c8400221c --- /dev/null +++ b/src/components/ProgressIndicator/hooks.ts @@ -0,0 +1,78 @@ +import * as React from 'react'; + +import { + cancelAnimation, + Easing, + type SharedValue, + useAnimatedReaction, + useSharedValue, + withRepeat, + withTiming, +} from 'react-native-reanimated'; +import { scheduleOnRN } from 'react-native-worklets'; + +const clamp = (value: number) => Math.min(1, Math.max(0, value)); + +/** + * One clock that drives every indeterminate animation. Returns a `phase` SharedValue that + * loops `0 -> 1` over `duration` ms (linear) while `indeterminate`, and holds `0` otherwise. + * Deriving every edge/angle from a single clock keeps the moving pieces in sync. + */ +export const useIndeterminatePhase = ( + indeterminate: boolean, + duration: number +): SharedValue => { + const phase = useSharedValue(0); + + React.useEffect(() => { + if (!indeterminate) { + cancelAnimation(phase); + phase.value = 0; + return; + } + + phase.value = 0; + phase.value = withRepeat( + withTiming(1, { duration, easing: Easing.linear }), + -1, + false + ); + + return () => cancelAnimation(phase); + }, [indeterminate, duration, phase]); + + return phase; +}; + +/** + * Whole-percent progress and busy state for the accessibility value. A numeric `progress` + * maps directly; an animated SharedValue is mirrored to React state via a determinate-gated + * reaction so it stays correct without re-rendering every frame. + */ +export const useProgressAccessibility = ( + progress: number | SharedValue, + indeterminate: boolean +): { progressPercent: number; busy: boolean } => { + const numberProgress = typeof progress === 'number' ? clamp(progress) : null; + const [animatedPercent, setAnimatedPercent] = React.useState(0); + const progressPercent = + numberProgress !== null + ? Math.round(numberProgress * 100) + : animatedPercent; + const busy = indeterminate || progressPercent < 100; + + useAnimatedReaction( + () => { + if (indeterminate || typeof progress === 'number') return -1; + return Math.round(Math.min(1, Math.max(0, progress.value)) * 100); + }, + (current, previous) => { + if (current !== -1 && current !== previous) { + scheduleOnRN(setAnimatedPercent, current); + } + }, + [progress, indeterminate] + ); + + return { progressPercent, busy }; +}; diff --git a/src/components/ProgressIndicator/indeterminateSpecs.ts b/src/components/ProgressIndicator/indeterminateSpecs.ts new file mode 100644 index 0000000000..2ed2bfd513 --- /dev/null +++ b/src/components/ProgressIndicator/indeterminateSpecs.ts @@ -0,0 +1,37 @@ +/** + * MD3 timing for the indeterminate linear progress animation. Shared by the linear + * progress indicators. + * + * One 1750ms cycle moves two lines. Each line has a head and a tail edge. An edge waits + * for its `delay`, eases from 0 to 1 over its `duration`, then stays at 1 until the cycle + * repeats. The head is always ahead of the tail, so a line covers the range [tail, head]. + */ +import { Easing } from 'react-native-reanimated'; + +import { motionEasing } from '../../theme/tokens/sys/motion'; + +export const LINEAR_INDETERMINATE_DURATION = 1750; + +export const LINEAR_INDETERMINATE_LINES = { + firstHead: { delay: 0, duration: 1000 }, + firstTail: { delay: 250, duration: 1000 }, + secondHead: { delay: 650, duration: 850 }, + secondTail: { delay: 900, duration: 850 }, +} as const; + +const [ACCEL_X1, ACCEL_Y1, ACCEL_X2, ACCEL_Y2] = + motionEasing.emphasizedAccelerate; + +/** + * Eased 0..1 position of one line edge, `elapsed` ms into the + * {@link LINEAR_INDETERMINATE_DURATION} cycle. All edges read the same `elapsed` (one clock), + * which keeps them in sync. Separate timers would drift by a frame at the cycle boundary and + * flash a full-width segment. + */ +export function lineFraction(elapsed: number, delay: number, duration: number) { + 'worklet'; + if (elapsed <= delay) return 0; + const local = (elapsed - delay) / duration; + if (local >= 1) return 1; + return Easing.bezierFn(ACCEL_X1, ACCEL_Y1, ACCEL_X2, ACCEL_Y2)(local); +} diff --git a/src/components/ProgressIndicator/utils.ts b/src/components/ProgressIndicator/utils.ts new file mode 100644 index 0000000000..51803aa64b --- /dev/null +++ b/src/components/ProgressIndicator/utils.ts @@ -0,0 +1,31 @@ +import type { ColorValue } from 'react-native'; + +import type { InternalTheme } from '../../types'; + +export type ProgressIndicatorColors = { + activeColor: ColorValue; + trackColor: ColorValue; + stopColor: ColorValue; +}; + +/** + * Resolves the colors shared by every progress indicator. A passed `color` / `trackColor` + * wins; otherwise the default MD3 roles apply: `primary` for the active and stop parts, + * `secondaryContainer` for the track. + */ +export const getProgressIndicatorColors = ({ + theme, + color, + trackColor, +}: { + theme: InternalTheme; + color?: ColorValue; + trackColor?: ColorValue; +}): ProgressIndicatorColors => { + const activeColor = color || theme.colors.primary; + return { + activeColor, + trackColor: trackColor || theme.colors.secondaryContainer, + stopColor: activeColor, + }; +}; diff --git a/src/components/ProgressIndicator/wavePath.ts b/src/components/ProgressIndicator/wavePath.ts new file mode 100644 index 0000000000..8417e5e9e4 --- /dev/null +++ b/src/components/ProgressIndicator/wavePath.ts @@ -0,0 +1,154 @@ +/** + * Worklet path builders for the wavy progress indicators. They run inside `useAnimatedProps` + * on the UI thread, so they must stay self-contained (no outside captures beyond their args) + * and return an SVG `d` string. Coordinates are rounded to 2dp to keep the string small. + */ + +const round = (value: number) => { + 'worklet'; + return Math.round(value * 100) / 100; +}; + +export const NO_DRAW_PATH = 'M0,0'; + +/** + * Straight horizontal segment from `x0` to `x1` at height `y`. Used for the track and as the + * flat fallback when the wave amplitude is 0. + */ +export function buildStraightPath(x0: number, x1: number, y: number): string { + 'worklet'; + if (x1 <= x0) return NO_DRAW_PATH; + return `M${round(x0)},${round(y)}L${round(x1)},${round(y)}`; +} + +/** + * Wavy segment from `tailX` to `headX`, centered on `centerY`. The wave is a chain of quadratic + * curves, two per `wavelength`: anchors sit on the centerline every half wavelength and control + * points sit at the quarter points, with the bump direction flipping each half wave. A quadratic + * peaks at half its control height, so the control offset is `2 * amplitude` to make the visible + * peak equal `amplitude`. + * + * The wave slides by `waveShift` (px): the anchor grid is `(x + waveShift) / halfWave`, so the + * pattern moves under a fixed window. Only the visible `[tailX, headX]` range is emitted; the + * boundary cells are cut precisely. Because each cell's control point sits at the midpoint in x, + * x is linear in the curve parameter, so a target x maps to a parameter by simple interpolation. + * + * `amplitude <= 0` returns a straight line (track / wave switched off / reduce motion). + */ +export function buildLinearWavePath( + tailX: number, + headX: number, + centerY: number, + amplitude: number, + wavelength: number, + waveShift: number +): string { + 'worklet'; + if (headX <= tailX) return NO_DRAW_PATH; + if (amplitude <= 0) return buildStraightPath(tailX, headX, centerY); + + const halfWave = wavelength / 2; + const controlY = amplitude * 2; + + let cell = Math.floor((tailX + waveShift) / halfWave); + let x0 = cell * halfWave - waveShift; + let d = ''; + let started = false; + + while (x0 < headX) { + const x2 = x0 + halfWave; + // Bump direction alternates per cell; negative is up in the y-down SVG space. + const sign = (((cell % 2) + 2) % 2 === 0 ? -1 : 1) * controlY; + const cx = x0 + halfWave / 2; + const cy = centerY + sign; + + // Clip this cell to the visible window via the linear x->parameter mapping. + const t0 = Math.max(0, (tailX - x0) / halfWave); + const t1 = Math.min(1, (headX - x0) / halfWave); + + // Quadratic with P0=(x0,centerY), P1=(cx,cy), P2=(x2,centerY). Endpoints come from the + // curve at t0/t1; the sub-segment control point comes from the blossom of (t0, t1). + const a0 = 1 - t0; + const a1 = 1 - t1; + const startX = a0 * a0 * x0 + 2 * a0 * t0 * cx + t0 * t0 * x2; + const startY = a0 * a0 * centerY + 2 * a0 * t0 * cy + t0 * t0 * centerY; + const endX = a1 * a1 * x0 + 2 * a1 * t1 * cx + t1 * t1 * x2; + const endY = a1 * a1 * centerY + 2 * a1 * t1 * cy + t1 * t1 * centerY; + const w0 = a0 * a1; + const w1 = a0 * t1 + t0 * a1; + const w2 = t0 * t1; + const ctrlX = w0 * x0 + w1 * cx + w2 * x2; + const ctrlY = w0 * centerY + w1 * cy + w2 * centerY; + + if (!started) { + d += `M${round(startX)},${round(startY)}`; + started = true; + } + d += `Q${round(ctrlX)},${round(ctrlY)} ${round(endX)},${round(endY)}`; + + cell += 1; + x0 = x2; + } + + return d; +} + +const SAMPLES_PER_WAVE = 16; + +/** + * Wavy arc centered on `(cx, cy)`, running clockwise from `startAngleDeg` for `sweepDeg` degrees. + * The wave is a closed polar sinusoid `r = radius + amplitude * sin(waveCount * theta + phase)`, + * sampled as a polyline. `waveCount` must be an integer so the pattern joins seamlessly around the + * full circle. Motion comes from advancing `phase`. + * + * `amplitude <= 0` returns a plain arc (track / wave switched off / reduce motion). + */ +export function buildArcWavePath( + cx: number, + cy: number, + radius: number, + startAngleDeg: number, + sweepDeg: number, + amplitude: number, + waveCount: number, + phase: number +): string { + 'worklet'; + if (sweepDeg <= 0) return NO_DRAW_PATH; + // Stop just short of a full turn: a 360deg arc would close on itself (the open path's round caps + // would snap to a seamless join, and a single SVG arc with coincident endpoints draws nothing). + // The round caps cover the hairline gap, so the ring still reads as complete, with no jump. + const sweep = Math.min(sweepDeg, 359.5); + + const toRad = Math.PI / 180; + const startRad = startAngleDeg * toRad; + const sweepRad = sweep * toRad; + + if (amplitude <= 0) { + const x0 = cx + radius * Math.cos(startRad); + const y0 = cy + radius * Math.sin(startRad); + const r = round(radius); + const endRad = startRad + sweepRad; + const x1 = cx + radius * Math.cos(endRad); + const y1 = cy + radius * Math.sin(endRad); + const largeArc = sweep > 180 ? 1 : 0; + return `M${round(x0)},${round(y0)}A${r},${r} 0 ${largeArc} 1 ${round( + x1 + )},${round(y1)}`; + } + + const steps = Math.max( + 2, + Math.ceil((sweepRad / (2 * Math.PI)) * waveCount * SAMPLES_PER_WAVE) + ); + const step = sweepRad / steps; + let d = ''; + for (let i = 0; i <= steps; i++) { + const theta = startRad + i * step; + const r = radius + amplitude * Math.sin(waveCount * theta + phase); + const x = cx + r * Math.cos(theta); + const y = cy + r * Math.sin(theta); + d += `${i === 0 ? 'M' : 'L'}${round(x)},${round(y)}`; + } + return d; +} diff --git a/src/components/Searchbar.tsx b/src/components/Searchbar.tsx index 975af841a6..91d98bf598 100644 --- a/src/components/Searchbar.tsx +++ b/src/components/Searchbar.tsx @@ -13,11 +13,11 @@ import { ViewStyle, } from 'react-native'; -import ActivityIndicator from './ActivityIndicator'; import Divider from './Divider'; import type { IconSource } from './Icon'; import IconButton from './IconButton/IconButton'; import MaterialCommunityIcon from './MaterialCommunityIcon'; +import CircularProgressIndicator from './ProgressIndicator/CircularProgressIndicator'; import Surface from './Surface'; import { useLocale } from '../core/locale'; import { useInternalTheme } from '../core/theming'; @@ -301,8 +301,9 @@ const Searchbar = forwardRef( {...rest} /> {loading ? ( - ) : ( diff --git a/src/components/__tests__/ActivityIndicator.test.tsx b/src/components/__tests__/ActivityIndicator.test.tsx deleted file mode 100644 index 1b276f9234..0000000000 --- a/src/components/__tests__/ActivityIndicator.test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import * as React from 'react'; - -import { render } from '../../test-utils'; -import ActivityIndicator from '../ActivityIndicator'; - -it('renders indicator', () => { - const tree = render().toJSON(); - - expect(tree).toMatchSnapshot(); -}); - -it('renders hidden indicator', () => { - const tree = render( - - ).toJSON(); - - expect(tree).toMatchSnapshot(); -}); - -it('renders large indicator', () => { - const tree = render().toJSON(); - - expect(tree).toMatchSnapshot(); -}); - -it('renders colored indicator', () => { - const tree = render().toJSON(); - - expect(tree).toMatchSnapshot(); -}); diff --git a/src/components/__tests__/ProgressBar.test.tsx b/src/components/__tests__/ProgressBar.test.tsx deleted file mode 100644 index 0a3445b601..0000000000 --- a/src/components/__tests__/ProgressBar.test.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import * as React from 'react'; -import { Animated, Platform, StyleSheet } from 'react-native'; - -import { act } from '@testing-library/react-native'; - -import { render } from '../../test-utils'; -import ProgressBar, { Props } from '../ProgressBar'; - -const layoutEvent = { - nativeEvent: { - layout: { - width: 100, - }, - }, -}; - -const styles = StyleSheet.create({ - fill: { - borderRadius: 4, - }, -}); - -const a11yRole = 'progressbar'; -const triggerLayout = async ( - tree: ReturnType -): Promise => { - await act(async () => { - tree.getByRole(a11yRole).props.onLayout(layoutEvent); - }); -}; - -class ClassProgressBar extends React.Component { - render() { - return ; - } -} - -const AnimatedProgressBar = Animated.createAnimatedComponent(ClassProgressBar); - -afterEach(() => { - Platform.OS = 'ios'; -}); - -it('renders progress bar with animated value', async () => { - const tree = render(); - await triggerLayout(tree); - - tree.update(); - - expect(tree.getByRole(a11yRole)).toBeTruthy(); -}); - -it('renders progress bar with specific progress', async () => { - const tree = render(); - await triggerLayout(tree); - - expect(tree.toJSON()).toMatchSnapshot(); -}); - -it('renders hidden progress bar', async () => { - const tree = render(); - await triggerLayout(tree); - - expect(tree.toJSON()).toMatchSnapshot(); -}); - -it('renders colored progress bar', async () => { - const tree = render(); - await triggerLayout(tree); - - expect(tree.toJSON()).toMatchSnapshot(); -}); - -it('renders indeterminate progress bar', async () => { - const tree = render(); - await triggerLayout(tree); - - expect(tree.toJSON()).toMatchSnapshot(); -}); - -it('renders progress bar with full height on web', () => { - Platform.OS = 'web'; - const tree = render(); - - expect(tree.getByRole(a11yRole)).toHaveStyle({ - width: '100%', - height: '100%', - }); -}); - -it('renders progress bar with custom style of filled part', async () => { - const tree = render( - - ); - await triggerLayout(tree); - - expect(tree.getByTestId('progress-bar-fill')).toHaveStyle({ - borderRadius: 4, - }); -}); diff --git a/src/components/__tests__/Searchbar.test.tsx b/src/components/__tests__/Searchbar.test.tsx index 255225f7ae..16417b1c03 100644 --- a/src/components/__tests__/Searchbar.test.tsx +++ b/src/components/__tests__/Searchbar.test.tsx @@ -21,22 +21,22 @@ it('renders with text', () => { expect(tree).toMatchSnapshot(); }); -it('activity indicator snapshot test', () => { +it('loading indicator snapshot test', () => { const tree = render().toJSON(); expect(tree).toMatchSnapshot(); }); -it('renders with ActivityIndicator', () => { +it('renders the loading indicator', () => { const tree = render(); - expect(tree.getByTestId('activity-indicator')).toBeTruthy(); + expect(tree.getByTestId('loading-indicator')).toBeTruthy(); }); -it('renders without ActivityIndicator', () => { +it('renders without the loading indicator', () => { const { getByTestId } = render(); - expect(() => getByTestId('activity-indicator')).toThrow(); + expect(() => getByTestId('loading-indicator')).toThrow(); }); it('renders clear icon with custom color', () => { diff --git a/src/components/__tests__/__snapshots__/ActivityIndicator.test.tsx.snap b/src/components/__tests__/__snapshots__/ActivityIndicator.test.tsx.snap deleted file mode 100644 index 43b4eac37e..0000000000 --- a/src/components/__tests__/__snapshots__/ActivityIndicator.test.tsx.snap +++ /dev/null @@ -1,793 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`renders colored indicator 1`] = ` - - - - - - - - - - - - - - - - - - - - - - - - - - -`; - -exports[`renders hidden indicator 1`] = ` - - - - - - - - - - - - - - - - - - - - - - - - - - -`; - -exports[`renders indicator 1`] = ` - - - - - - - - - - - - - - - - - - - - - - - - - - -`; - -exports[`renders large indicator 1`] = ` - - - - - - - - - - - - - - - - - - - - - - - - - - -`; diff --git a/src/components/__tests__/__snapshots__/Button.test.tsx.snap b/src/components/__tests__/__snapshots__/Button.test.tsx.snap index 992205a103..0ea807ab17 100644 --- a/src/components/__tests__/__snapshots__/Button.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Button.test.tsx.snap @@ -1576,12 +1576,13 @@ exports[`renders loading button 1`] = ` "busy": true, } } + accessibilityValue={{}} accessible={true} style={ [ { - "alignItems": "center", - "justifyContent": "center", + "height": 18, + "width": 18, }, [ { @@ -1599,181 +1600,54 @@ exports[`renders loading button 1`] = ` ], ] } + testID="circular-progress-indicator" > - - - - - - - - - - - + "rotate": "0deg", + }, + ], + }, + ] + } + > - - - - - - - - + cx={9} + cy={9} + fill="none" + r={8.1} + stroke="rgba(103, 80, 164, 1)" + strokeDasharray={ + [ + 50.893800988154645, + 50.893800988154645, + ] + } + strokeLinecap="round" + strokeWidth={1.8} + testID="circular-progress-indicator-active" + /> diff --git a/src/components/__tests__/__snapshots__/ProgressBar.test.tsx.snap b/src/components/__tests__/__snapshots__/ProgressBar.test.tsx.snap deleted file mode 100644 index ae5c19ff75..0000000000 --- a/src/components/__tests__/__snapshots__/ProgressBar.test.tsx.snap +++ /dev/null @@ -1,211 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`renders colored progress bar 1`] = ` - - - - - -`; - -exports[`renders hidden progress bar 1`] = ` - - - - - -`; - -exports[`renders indeterminate progress bar 1`] = ` - - - - - -`; - -exports[`renders progress bar with specific progress 1`] = ` - - - - - -`; diff --git a/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap b/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap index bbe1779a66..b65d99a9cf 100644 --- a/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`activity indicator snapshot test 1`] = ` +exports[`loading indicator snapshot test 1`] = ` - - - - - - - - - - - + "rotate": "0deg", + }, + ], + }, + ] + } + > - - - - - - - - + cx={20} + cy={20} + fill="none" + r={18} + stroke="rgba(103, 80, 164, 1)" + strokeDasharray={ + [ + 113.09733552923255, + 113.09733552923255, + ] + } + strokeLinecap="round" + strokeWidth={4} + testID="loading-indicator-active" + /> diff --git a/src/index.tsx b/src/index.tsx index 8863e2fa20..26e7617571 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -22,23 +22,25 @@ import * as List from './components/List/List'; export { Avatar, List, Drawer }; export { default as Badge } from './components/Badge'; -export { default as ActivityIndicator } from './components/ActivityIndicator'; export { default as Banner } from './components/Banner'; export { default as BottomNavigation } from './components/BottomNavigation/BottomNavigation'; export { default as Button } from './components/Button/Button'; export { default as Card } from './components/Card/Card'; export { default as Checkbox } from './components/Checkbox'; export { default as Chip } from './components/Chip/Chip'; +export { default as CircularProgressIndicator } from './components/ProgressIndicator/CircularProgressIndicator/CircularProgressIndicator'; +export { default as CircularWavyProgressIndicator } from './components/ProgressIndicator/CircularWavyProgressIndicator/CircularWavyProgressIndicator'; export { default as DataTable } from './components/DataTable/DataTable'; export { default as Dialog } from './components/Dialog/Dialog'; export { default as Divider } from './components/Divider'; export { default as FAB } from './components/FAB'; export { default as Icon } from './components/Icon'; export { default as IconButton } from './components/IconButton/IconButton'; +export { default as LinearProgressIndicator } from './components/ProgressIndicator/LinearProgressIndicator/LinearProgressIndicator'; +export { default as LinearWavyProgressIndicator } from './components/ProgressIndicator/LinearWavyProgressIndicator/LinearWavyProgressIndicator'; export { default as Menu } from './components/Menu/Menu'; export { default as Modal } from './components/Modal'; export { default as Portal } from './components/Portal/Portal'; -export { default as ProgressBar } from './components/ProgressBar'; export { default as RadioButton } from './components/RadioButton'; export { default as Searchbar } from './components/Searchbar'; export { default as Snackbar } from './components/Snackbar'; @@ -54,7 +56,6 @@ export { default as Tooltip } from './components/Tooltip/Tooltip'; export { default as Text, customText } from './components/Typography/Text'; // Types -export type { Props as ActivityIndicatorProps } from './components/ActivityIndicator'; export type { Props as AppbarProps } from './components/Appbar/Appbar'; export type { Props as AppbarActionProps } from './components/Appbar/AppbarAction'; export type { Props as AppbarBackActionProps } from './components/Appbar/AppbarBackAction'; @@ -78,6 +79,8 @@ export type { Props as CardTitleProps } from './components/Card/CardTitle'; export type { Props as CheckboxProps } from './components/Checkbox/Checkbox'; export type { Props as CheckboxItemProps } from './components/Checkbox/CheckboxItem'; export type { Props as ChipProps } from './components/Chip/Chip'; +export type { Props as CircularProgressIndicatorProps } from './components/ProgressIndicator/CircularProgressIndicator/CircularProgressIndicator'; +export type { Props as CircularWavyProgressIndicatorProps } from './components/ProgressIndicator/CircularWavyProgressIndicator/CircularWavyProgressIndicator'; export type { Props as DataTableProps } from './components/DataTable/DataTable'; export type { Props as DataTableCellProps } from './components/DataTable/DataTableCell'; export type { Props as DataTableHeaderProps } from './components/DataTable/DataTableHeader'; @@ -106,6 +109,8 @@ export type { Size as FABSize, } from './components/FAB/tokens'; export type { Props as IconButtonProps } from './components/IconButton/IconButton'; +export type { Props as LinearProgressIndicatorProps } from './components/ProgressIndicator/LinearProgressIndicator/LinearProgressIndicator'; +export type { Props as LinearWavyProgressIndicatorProps } from './components/ProgressIndicator/LinearWavyProgressIndicator/LinearWavyProgressIndicator'; export type { Props as ListAccordionProps } from './components/List/ListAccordion'; export type { Props as ListAccordionGroupProps } from './components/List/ListAccordionGroup'; export type { Props as ListIconProps } from './components/List/ListIcon'; @@ -117,7 +122,6 @@ export type { Props as MenuItemProps } from './components/Menu/MenuItem'; export type { Props as ModalProps } from './components/Modal'; export type { Props as PortalProps } from './components/Portal/Portal'; export type { Props as PortalHostProps } from './components/Portal/PortalHost'; -export type { Props as ProgressBarProps } from './components/ProgressBar'; export type { Props as ProviderProps } from './core/PaperProvider'; export type { Props as RadioButtonProps } from './components/RadioButton/RadioButton'; export type { Props as RadioButtonAndroidProps } from './components/RadioButton/RadioButtonAndroid'; diff --git a/yarn.lock b/yarn.lock index 07931713e5..b12667310b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20587,6 +20587,7 @@ __metadata: react-native-reanimated: "npm:4.3.1" react-native-safe-area-context: "npm:~5.7.0" react-native-screens: "npm:4.25.1" + react-native-svg: "npm:15.15.4" react-native-web: "npm:^0.21.0" react-native-worklets: "npm:0.8.3" languageName: unknown @@ -20641,6 +20642,7 @@ __metadata: react-native-builder-bob: "npm:^0.41.0" react-native-reanimated: "npm:4.3.1" react-native-safe-area-context: "npm:5.7.0" + react-native-svg: "npm:15.15.4" react-native-worklets: "npm:0.8.3" react-test-renderer: "npm:19.2.3" release-it: "npm:^13.4.0" @@ -20652,6 +20654,7 @@ __metadata: react-native: "*" react-native-reanimated: ">=4.3.0" react-native-safe-area-context: "*" + react-native-svg: ">=15.0.0" react-native-worklets: ">=0.8.1" languageName: unknown linkType: soft @@ -20703,6 +20706,20 @@ __metadata: languageName: node linkType: hard +"react-native-svg@npm:15.15.4": + version: 15.15.4 + resolution: "react-native-svg@npm:15.15.4" + dependencies: + css-select: "npm:^5.1.0" + css-tree: "npm:^1.1.3" + warn-once: "npm:0.1.1" + peerDependencies: + react: "*" + react-native: "*" + checksum: 10c0/1fb8e3ac9d45a4db74731a006cd32f883051844f361974dff49e1a4142aa7c1a0d87e0b04fff06a1932ca53940bcfb94e45e01a845eb451d4659fbf07092629e + languageName: node + linkType: hard + "react-native-web@npm:^0.18.12": version: 0.18.12 resolution: "react-native-web@npm:0.18.12" @@ -24749,7 +24766,7 @@ __metadata: languageName: node linkType: hard -"warn-once@npm:^0.1.0, warn-once@npm:^0.1.1": +"warn-once@npm:0.1.1, warn-once@npm:^0.1.0, warn-once@npm:^0.1.1": version: 0.1.1 resolution: "warn-once@npm:0.1.1" checksum: 10c0/f531e7b2382124f51e6d8f97b8c865246db8ab6ff4e53257a2d274e0f02b97d7201eb35db481843dc155815e154ad7afb53b01c4d4db15fb5aa073562496aff7