diff --git a/example/src/navigation/navigator.tsx b/example/src/navigation/navigator.tsx index b726e752..7b217144 100644 --- a/example/src/navigation/navigator.tsx +++ b/example/src/navigation/navigator.tsx @@ -6,6 +6,8 @@ import { Badges } from "../pages/Badges"; import { Icons } from "../pages/Icons"; import { Layout } from "../pages/Layout"; import { Logos } from "../pages/Logos"; +import { Selection } from "../pages/Selection"; + import MainScreen from "../pages/MainScreen"; import { Pictograms } from "../pages/Pictograms"; import { Typography } from "../pages/Typography"; @@ -90,6 +92,14 @@ const AppNavigator = () => ( headerBackTitleVisible: false }} /> + ); diff --git a/example/src/pages/Selection.tsx b/example/src/pages/Selection.tsx new file mode 100644 index 00000000..4db8bad1 --- /dev/null +++ b/example/src/pages/Selection.tsx @@ -0,0 +1,274 @@ +import { + CheckboxLabel, + ListItemCheckbox, + Divider, + H2, + NativeSwitch, + NewRadioItem, + RadioGroup, + SwitchLabel, + ListItemSwitch, + VSpacer +} from "@pagopa/io-app-design-system"; +import React, { useState } from "react"; +import { View } from "react-native"; +import { ComponentViewerBox } from "../components/ComponentViewerBox"; +import { Screen } from "../components/Screen"; + +export const Selection = () => ( + +

+ Checkbox +

+ {/* CheckboxLabel */} + {renderCheckboxLabel()} + {/* ListItemCheckbox */} + {renderListItemCheckbox()} +

+ Radio +

+ {/* RadioListItem */} + +

+ Switch +

+ {/* Native Switch */} + + {/* ListItemSwitch */} + + {/* SwitchLabel */} + {renderAnimatedSwitch()} +
+); + +const renderCheckboxLabel = () => ( + <> + + + + + + + + + + + +); + +const renderListItemCheckbox = () => ( + <> + + + + + + + + + + + + + + + + + + + + + +); + +// RADIO ITEMS + +const mockRadioItems = (): ReadonlyArray> => [ + { + icon: "coggle", + value: "Let's try with a basic title", + description: + "Ti contatteranno solo i servizi che hanno qualcosa di importante da dirti. Potrai sempre disattivare le comunicazioni che non ti interessano.", + id: "example-1" + }, + { + value: "Let's try with a basic title", + description: + "Ti contatteranno solo i servizi che hanno qualcosa di importante da dirti.", + id: "example-2" + }, + { + value: "Let's try with a very looong loooooong title instead", + id: "example-3" + }, + { + value: "Let's try with a disabled item", + description: + "Ti contatteranno solo i servizi che hanno qualcosa di importante da dirti.", + id: "example-disabled", + disabled: true + } +]; + +const RadioListItemsShowroom = () => { + const [selectedItem, setSelectedItem] = useState( + "example-1" + ); + + return ( + + + key="check_income" + items={mockRadioItems()} + selectedItem={selectedItem} + onPress={setSelectedItem} + /> + + ); +}; + +// SWITCH + +const renderAnimatedSwitch = () => ( + + + + + +); + +const NativeSwitchShowroom = () => { + const [isEnabled, setIsEnabled] = useState(false); + const toggleSwitch = () => setIsEnabled(previousState => !previousState); + + return ( + + + + + + ); +}; + +type ListItemSwitchSampleProps = Pick< + React.ComponentProps, + "label" | "description" | "value" +>; + +const ListItemSwitchSample = ({ + value, + label, + description +}: ListItemSwitchSampleProps) => { + const [isEnabled, setIsEnabled] = useState(value); + const toggleSwitch = () => setIsEnabled(previousState => !previousState); + + return ( + + ); +}; + +const ListItemSwitchShowroom = () => ( + <> + + + + + + + + + + + + + + + + + +); diff --git a/src/components/buttons/ButtonLink.tsx b/src/components/buttons/ButtonLink.tsx index 2e4eb731..7e131548 100644 --- a/src/components/buttons/ButtonLink.tsx +++ b/src/components/buttons/ButtonLink.tsx @@ -1,96 +1,98 @@ import React, { useCallback } from "react"; -import { - GestureResponderEvent, - Pressable, - StyleSheet -} from "react-native"; +import { GestureResponderEvent, Pressable, StyleSheet } from "react-native"; import Animated, { - Extrapolate, - interpolate, - interpolateColor, - useAnimatedProps, - useAnimatedStyle, - useDerivedValue, - useSharedValue, - withSpring + Extrapolate, + interpolate, + interpolateColor, + useAnimatedProps, + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withSpring } from "react-native-reanimated"; import { WithTestID } from "../../utils/types"; import { makeFontStyleObject } from "../../utils/fonts"; import { AnimatedIcon, IOIcons, IconClassComponent } from "../icons/Icon"; import { HSpacer } from "../spacer/Spacer"; -import { IOScaleValues, IOSpringValues, IOColors, IOButtonStyles } from "../../core"; +import { + IOScaleValues, + IOSpringValues, + IOColors, + IOButtonStyles +} from "../../core"; type ColorButtonLink = "primary" | "error" | "warning" | "success" | "info"; export type ButtonLink = WithTestID<{ - color?: ColorButtonLink; - label: string; - disabled?: boolean; - // Icons - icon?: IOIcons; - iconPosition?: "start" | "end"; - // Accessibility - accessibilityLabel?: string; - accessibilityHint?: string; - // Events - onPress: (event: GestureResponderEvent) => void; + color?: ColorButtonLink; + label: string; + disabled?: boolean; + // Icons + icon?: IOIcons; + iconPosition?: "start" | "end"; + // Accessibility + accessibilityLabel?: string; + accessibilityHint?: string; + // Events + onPress: (event: GestureResponderEvent) => void; }>; type ColorStates = { - label: { - default: string; - pressed: string; - disabled: string; - }; + label: { + default: string; + pressed: string; + disabled: string; + }; }; const mapColorStates: Record, ColorStates> = { - // Primary button - primary: { - label: { - default: IOColors["blueIO-500"], - pressed: IOColors["blueIO-600"], - disabled: IOColors["grey-700"] - } - }, - error: { - label: { - default: IOColors["error-850"], - pressed: IOColors["error-850"], - disabled: IOColors["grey-700"] - } - }, - warning: { - label: { - default: IOColors["warning-850"], - pressed: IOColors["warning-850"], - disabled: IOColors["grey-700"] - } - }, - success: { - label: { - default: IOColors["success-850"], - pressed: IOColors["success-850"], - disabled: IOColors["grey-700"] - } - }, - info: { - label: { - default: IOColors["info-850"], - pressed: IOColors["info-850"], - disabled: IOColors["grey-700"] - } + // Primary button + primary: { + label: { + default: IOColors["blueIO-500"], + pressed: IOColors["blueIO-600"], + disabled: IOColors["grey-700"] } + }, + error: { + label: { + default: IOColors["error-850"], + pressed: IOColors["error-850"], + disabled: IOColors["grey-700"] + } + }, + warning: { + label: { + default: IOColors["warning-850"], + pressed: IOColors["warning-850"], + disabled: IOColors["grey-700"] + } + }, + success: { + label: { + default: IOColors["success-850"], + pressed: IOColors["success-850"], + disabled: IOColors["grey-700"] + } + }, + info: { + label: { + default: IOColors["info-850"], + pressed: IOColors["info-850"], + disabled: IOColors["grey-700"] + } + } }; const DISABLED_OPACITY = 0.5; const IOButtonStylesLocal = StyleSheet.create({ - label: { - fontSize: 16, - ...makeFontStyleObject("Regular", false, "ReadexPro") - } + label: { + fontSize: 16, + ...makeFontStyleObject("Regular", false, "ReadexPro") + } }); -export const ButtonLink = React.memo(({ +export const ButtonLink = React.memo( + ({ color = "primary", label, disabled = false, @@ -100,7 +102,7 @@ export const ButtonLink = React.memo(({ accessibilityLabel, accessibilityHint, testID -}: ButtonLink) => { + }: ButtonLink) => { const isPressed = useSharedValue(0); // Scaling transformation applied when the button is pressed @@ -108,138 +110,136 @@ export const ButtonLink = React.memo(({ // Using a spring-based animation for our interpolations const progressPressed = useDerivedValue(() => - withSpring(isPressed.value, IOSpringValues.button) + withSpring(isPressed.value, IOSpringValues.button) ); // Interpolate animation values from `isPressed` values const pressedAnimationStyle = useAnimatedStyle(() => { - // Link color states to the pressed states - - // Scale down button slightly when pressed - const scale = interpolate( - progressPressed.value, - [0, 1], - [1, animationScaleValue], - Extrapolate.CLAMP - ); - - return { - transform: [{ scale }] - }; + // Link color states to the pressed states + + // Scale down button slightly when pressed + const scale = interpolate( + progressPressed.value, + [0, 1], + [1, animationScaleValue], + Extrapolate.CLAMP + ); + + return { + transform: [{ scale }] + }; }); const pressedColorLabelAnimationStyle = useAnimatedStyle(() => { - // Link color states to the pressed states - - /* ◀ REMOVE_LEGACY_COMPONENT: Remove the following condition */ - const labelColor = interpolateColor( - progressPressed.value, - [0, 1], - [ - mapColorStates[color].label.default, - mapColorStates[color].label.pressed - ] - ); - - return { - color: labelColor - }; + // Link color states to the pressed states + + const labelColor = interpolateColor( + progressPressed.value, + [0, 1], + [ + mapColorStates[color].label.default, + mapColorStates[color].label.pressed + ] + ); + + return { + color: labelColor + }; }); // Animate the color prop const pressedColorIconAnimationStyle = useAnimatedProps(() => { - const iconColor = interpolateColor( - progressPressed.value, - [0, 1], - [ - mapColorStates[color].label.default, - mapColorStates[color].label.pressed - ] - ); - - return { color: iconColor }; + const iconColor = interpolateColor( + progressPressed.value, + [0, 1], + [ + mapColorStates[color].label.default, + mapColorStates[color].label.pressed + ] + ); + + return { color: iconColor }; }); const AnimatedIconClassComponent = - Animated.createAnimatedComponent(IconClassComponent); + Animated.createAnimatedComponent(IconClassComponent); const onPressIn = useCallback(() => { - // eslint-disable-next-line functional/immutable-data - isPressed.value = 1; + // eslint-disable-next-line functional/immutable-data + isPressed.value = 1; }, [isPressed]); const onPressOut = useCallback(() => { - // eslint-disable-next-line functional/immutable-data - isPressed.value = 0; + // eslint-disable-next-line functional/immutable-data + isPressed.value = 0; }, [isPressed]); // Icon size const iconSize = 24; return ( - - + - {icon && ( - <> - {!disabled ? ( - - ) : ( - - )} - - - )} - + {icon && ( + <> + {!disabled ? ( + + ) : ( + + )} + + + )} + - {label} - - - + > + {label} + + + ); -}); + } +); export default ButtonLink; diff --git a/src/components/buttons/IconButton.tsx b/src/components/buttons/IconButton.tsx index 1e022bce..ccb1b301 100644 --- a/src/components/buttons/IconButton.tsx +++ b/src/components/buttons/IconButton.tsx @@ -10,7 +10,14 @@ import Animated, { useSharedValue, withSpring } from "react-native-reanimated"; -import { IOButtonStyles, IOColors, IOIconButtonStyles, IOScaleValues, IOSpringValues, hexToRgba } from "../../core"; +import { + IOButtonStyles, + IOColors, + IOIconButtonStyles, + IOScaleValues, + IOSpringValues, + hexToRgba +} from "../../core"; import { WithTestID } from "../../utils/types"; import { AnimatedIcon, IOIcons, IconClassComponent } from "../icons"; @@ -101,14 +108,10 @@ export const IconButton = ({ const iconColor = interpolateColor( progressPressed.value, [0, 1], - [ - mapColorStates[color].icon.default, - mapColorStates[color].icon.pressed - ] + [mapColorStates[color].icon.default, mapColorStates[color].icon.pressed] ); return { color: iconColor }; }); - /* REMOVE_LEGACY_COMPONENT: End ▶ */ const handlePressIn = useCallback(() => { // eslint-disable-next-line functional/immutable-data diff --git a/src/components/checkbox/AnimatedCheckbox.tsx b/src/components/checkbox/AnimatedCheckbox.tsx index 6db6ead4..26a878e5 100644 --- a/src/components/checkbox/AnimatedCheckbox.tsx +++ b/src/components/checkbox/AnimatedCheckbox.tsx @@ -85,7 +85,6 @@ export const AnimatedCheckbox = ({ checked, onPress, disabled }: OwnProps) => { onPress={onPress} style={styles.checkBoxWrapper} > - {/* ◀ REMOVE_LEGACY_COMPONENT: Remove the following conditions */} { animatedCheckboxSquare ]} /> - {/* REMOVE_LEGACY_COMPONENT: End ▶ */} {isChecked && ( void; +}; + +const DISABLED_OPACITY = 0.5; + +// disabled: the component is no longer touchable +// onPress: +type OwnProps = Props & + Pick< + React.ComponentProps, + "onPress" | "accessibilityLabel" | "disabled" + >; + +/** + * with the automatic state management that uses a {@link AnimatedCheckBox} + * The toggleValue change when a `onPress` event is received and dispatch the `onValueChange`. + * + * @param props + * @constructor + */ +export const ListItemCheckbox = ({ + value, + description, + icon, + selected, + disabled, + onValueChange +}: OwnProps) => { + const [toggleValue, setToggleValue] = useState(selected ?? false); + // Animations + const isPressed: Animated.SharedValue = useSharedValue(0); + + // Scaling transformation applied when the button is pressed + const animationScaleValue = IOScaleValues?.basicButton?.pressedState; + + const progressPressed = useDerivedValue(() => + withSpring(isPressed.value, IOSpringValues.button) + ); + + // Theme + const theme = useIOTheme(); + + const mapBackgroundStates: Record = { + default: hexToRgba(IOColors[theme["listItem-pressed"]], 0), + pressed: IOColors[theme["listItem-pressed"]] + }; + + // Interpolate animation values from `isPressed` values + const animatedScaleStyle = useAnimatedStyle(() => { + const scale = interpolate( + progressPressed.value, + [0, 1], + [1, animationScaleValue], + Extrapolate.CLAMP + ); + + return { + transform: [{ scale }] + }; + }); + + const handlePressIn = useCallback(() => { + // eslint-disable-next-line functional/immutable-data + isPressed.value = 1; + }, [isPressed]); + const handlePressOut = useCallback(() => { + // eslint-disable-next-line functional/immutable-data + isPressed.value = 0; + }, [isPressed]); + + const animatedBackgroundStyle = useAnimatedStyle(() => { + const backgroundColor = interpolateColor( + progressPressed.value, + [0, 1], + [mapBackgroundStates.default, mapBackgroundStates.pressed] + ); + + return { + backgroundColor + }; + }); + + const toggleCheckbox = () => { + ReactNativeHapticFeedback.trigger("impactLight"); + setToggleValue(!toggleValue); + if (onValueChange !== undefined) { + onValueChange(!toggleValue); + } + }; + + return ( + + + + + + {icon && ( + + + + )} +
+ {value} +
+
+ + + + +
+ {description && ( + + + + {description} + + + )} +
+
+
+ ); +}; diff --git a/src/components/listitems/ListItemRadio.tsx b/src/components/listitems/ListItemRadio.tsx new file mode 100644 index 00000000..ae5b89bd --- /dev/null +++ b/src/components/listitems/ListItemRadio.tsx @@ -0,0 +1,185 @@ +import * as React from "react"; +import { useCallback, useState } from "react"; +import { Pressable, View } from "react-native"; +import ReactNativeHapticFeedback from "react-native-haptic-feedback"; +import Animated, { + Extrapolate, + interpolate, + interpolateColor, + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withSpring +} from "react-native-reanimated"; +import { + IOColors, + IOScaleValues, + IOSelectionListItemStyles, + IOSelectionListItemVisualParams, + IOSpringValues, + IOStyles, + hexToRgba, + useIOTheme +} from "../../core"; +import { WithTestID } from "../../utils/types"; +import { IOIcons, Icon } from "../icons"; +import { HSpacer, VSpacer } from "../spacer"; +import { H6, LabelSmall } from "../typography"; +import { AnimatedRadio } from "../radio/AnimatedRadio"; + +type Props = WithTestID<{ + value: string; + description?: string; + icon?: IOIcons; + selected: boolean; + onValueChange?: (newValue: boolean) => void; +}>; + +const DISABLED_OPACITY = 0.5; + +// disabled: the component is no longer touchable +// onPress: +type OwnProps = Props & + Pick< + React.ComponentProps, + "onPress" | "accessibilityLabel" | "disabled" + >; + +/** + * with the automatic state management that uses a {@link AnimatedCheckBox} + * The toggleValue change when a `onPress` event is received and dispatch the `onValueChange`. + * + * @param props + * @constructor + */ +export const ListItemRadio = ({ + value, + description, + icon, + selected, + disabled, + onValueChange, + testID +}: OwnProps) => { + const [toggleValue, setToggleValue] = useState(selected ?? false); + // Animations + const isPressed: Animated.SharedValue = useSharedValue(0); + + // Scaling transformation applied when the button is pressed + const animationScaleValue = IOScaleValues?.basicButton?.pressedState; + + const progressPressed = useDerivedValue(() => + withSpring(isPressed.value, IOSpringValues.button) + ); + + // Theme + const theme = useIOTheme(); + + const mapBackgroundStates: Record = { + default: hexToRgba(IOColors[theme["listItem-pressed"]], 0), + pressed: IOColors[theme["listItem-pressed"]] + }; + + // Interpolate animation values from `isPressed` values + const animatedScaleStyle = useAnimatedStyle(() => { + const scale = interpolate( + progressPressed.value, + [0, 1], + [1, animationScaleValue], + Extrapolate.CLAMP + ); + + return { + transform: [{ scale }] + }; + }); + + const handlePressIn = useCallback(() => { + // eslint-disable-next-line functional/immutable-data + isPressed.value = 1; + }, [isPressed]); + const handlePressOut = useCallback(() => { + // eslint-disable-next-line functional/immutable-data + isPressed.value = 0; + }, [isPressed]); + + const animatedBackgroundStyle = useAnimatedStyle(() => { + const backgroundColor = interpolateColor( + progressPressed.value, + [0, 1], + [mapBackgroundStates.default, mapBackgroundStates.pressed] + ); + + return { + backgroundColor + }; + }); + + const toggleRadioItem = () => { + ReactNativeHapticFeedback.trigger("impactLight"); + setToggleValue(!toggleValue); + if (onValueChange !== undefined) { + onValueChange(!toggleValue); + } + }; + + return ( + + + + + + {icon && ( + + + + )} + +
+ {value} +
+
+ + + + +
+ {description && ( + + + + {description} + + + )} +
+
+
+ ); +}; diff --git a/src/components/listitems/ListItemSwitch.tsx b/src/components/listitems/ListItemSwitch.tsx new file mode 100644 index 00000000..bf152953 --- /dev/null +++ b/src/components/listitems/ListItemSwitch.tsx @@ -0,0 +1,94 @@ +import React from "react"; +import { Switch, View } from "react-native"; +import { + IOSelectionListItemStyles, + IOSelectionListItemVisualParams, + IOStyles, + useIOTheme +} from "../../core"; +import { IOIcons, Icon } from "../icons"; +import { HSpacer, VSpacer } from "../spacer"; +import { H6, LabelSmall } from "../typography"; +import { NativeSwitch } from "../switch/NativeSwitch"; + +type Props = { + label: string; + onSwitchValueChange?: (newValue: boolean) => void; + description?: string; + icon?: IOIcons; +}; + +const DISABLED_OPACITY = 0.5; + +type OwnProps = Props & + Pick, "value" | "disabled">; + +export const ListItemSwitch = React.memo( + ({ + label, + description, + icon, + value, + disabled, + onSwitchValueChange + }: OwnProps) => { + const theme = useIOTheme(); + + return ( + + + + + {icon && ( + + + + )} +
+ {label} +
+
+ {description && ( + <> + + + {description} + + + )} +
+ + + + + +
+
+ ); + } +); diff --git a/src/components/listitems/index.tsx b/src/components/listitems/index.tsx index 555bdc96..4c809073 100644 --- a/src/components/listitems/index.tsx +++ b/src/components/listitems/index.tsx @@ -6,3 +6,6 @@ export * from "./ListItemNav"; export * from "./ListItemNavAlert"; export * from "./ListItemTransaction"; export * from "./PressableListItemsBase"; +export * from "./ListItemSwitch"; +export * from "./ListItemCheckbox"; +export * from "./ListItemRadio"; diff --git a/src/components/radio/AnimatedRadio.tsx b/src/components/radio/AnimatedRadio.tsx index 6b06cac9..f471f306 100644 --- a/src/components/radio/AnimatedRadio.tsx +++ b/src/components/radio/AnimatedRadio.tsx @@ -83,7 +83,6 @@ export const AnimatedRadio = ({ checked, onPress, disabled }: OwnProps) => { onPress={onPress} style={styles.radioWrapper} > - {/* ◀ REMOVE_LEGACY_COMPONENT: Remove the following conditions */} { animatedCheckboxSquare ]} /> - {/* REMOVE_LEGACY_COMPONENT: End ▶ */} {isChecked && ( = { + id: T; + value: string; + description?: string; + icon?: IOIcons; + disabled?: boolean; +}; + +type Props = { + items: ReadonlyArray>; + selectedItem?: T; + onPress: (selected: T) => void; +}; + +/** + * A list of radio buttons. + * The management of the selection is demanded and derived by the `selectedItem` prop. + * The item with the `id` equal to the `selectedItem` is the active one. + */ +export const RadioGroup = ({ items, selectedItem, onPress }: Props) => ( + + {items.map((item, index) => ( + + onPress(item.id)} + selected={selectedItem === item.id} + /> + {index < items.length - 1 && } + + ))} + +); diff --git a/src/components/radio/index.tsx b/src/components/radio/index.tsx index e308948e..8b703f05 100644 --- a/src/components/radio/index.tsx +++ b/src/components/radio/index.tsx @@ -1 +1,2 @@ export * from "./AnimatedRadio"; +export * from "./RadioGroup";