diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 4122f36a..60cd5265 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -12,7 +12,7 @@ diff --git a/example/android/app/src/main/java/com/exampleapp/MainActivity.java b/example/android/app/src/main/java/com/exampleapp/MainActivity.java index ca4a01d3..9148620f 100644 --- a/example/android/app/src/main/java/com/exampleapp/MainActivity.java +++ b/example/android/app/src/main/java/com/exampleapp/MainActivity.java @@ -5,6 +5,8 @@ import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint; import com.facebook.react.defaults.DefaultReactActivityDelegate; +import android.os.Bundle; + public class MainActivity extends ReactActivity { /** @@ -16,6 +18,16 @@ protected String getMainComponentName() { return "ExampleApp"; } + /** + * Avoid crashing the application when the user changes the `fontScale` attribute + * and the UI is updated accordingly. + * To learn more: https://github.com/pagopa/io-app-design-system/pull/348 + */ + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(null); + } + /** * Returns the instance of the {@link ReactActivityDelegate}. Here we use a util class {@link * DefaultReactActivityDelegate} which allows you to easily enable Fabric and Concurrent React diff --git a/example/src/pages/Badges.tsx b/example/src/pages/Badges.tsx index cab16c4b..88c8b86d 100644 --- a/example/src/pages/Badges.tsx +++ b/example/src/pages/Badges.tsx @@ -142,5 +142,12 @@ const renderTag = () => ( + + + ); diff --git a/example/src/pages/ListItem.tsx b/example/src/pages/ListItem.tsx index 0ecda9fe..e8a85a4d 100644 --- a/example/src/pages/ListItem.tsx +++ b/example/src/pages/ListItem.tsx @@ -243,6 +243,7 @@ const renderListItemInfoCopy = () => ( }} accessibilityLabel="Empty just for testing purposes" /> + ( accessibilityLabel="Empty just for testing purposes" icon="institution" /> + ( accessibilityLabel="Empty just for testing purposes" icon="creditCard" /> + ({ - useBoldTextEnabled: () => false + useBoldTextEnabled: () => false, + useIOFontDynamicScale: () => ({ + dynamicFontScale: 1, + spacingScaleMultiplier: 1 + }) })); diff --git a/src/components/Advice/__test__/__snapshots__/advice.test.tsx.snap b/src/components/Advice/__test__/__snapshots__/advice.test.tsx.snap index 882d7233..54566b3c 100644 --- a/src/components/Advice/__test__/__snapshots__/advice.test.tsx.snap +++ b/src/components/Advice/__test__/__snapshots__/advice.test.tsx.snap @@ -86,7 +86,7 @@ exports[`Test Advice Components - Experimental Enabled Advice Snapshot 1`] = ` ( ): JSX.Element => { const { onPressIn, onPressOut, scaleAnimatedStyle } = useScaleAnimation("medium"); + const { dynamicFontScale, spacingScaleMultiplier } = + useIOFontDynamicScale(); const { themeType } = useIOThemeContext(); const [isMultiline, setIsMultiline] = useState(false); @@ -151,54 +136,53 @@ export const Alert = forwardRef( [] ); + const paddingDefaultVariant = { + padding, + borderRadius: IOAlertRadius * dynamicFontScale * spacingScaleMultiplier, + borderCurve: "continuous" + }; + const mapVariantStates = themeType === "light" ? mapVariantStatesLightMode : mapVariantStatesDarkMode; const renderMainBlock = () => ( - <> - - - + + {/* Sadly we don't have specific alignments style for text in React Native, like `text-box-trim` for CSS. So we have to put these magic numbers after manual adjustments. Tested on both Android and iOS. */} - {title && ( - <> + + {title && (

{title}

- - - )} - - {content} - - {action && ( - <> - + )} + + {content} + + {action && ( ( > {action} - - )} + )} +
- +
); const StaticComponent = () => ( ( > { +export const Badge = ({ + text, + outline = false, + allowFontScaling = true, + variant, + testID +}: Badge) => { const { isExperimental } = useIOExperimentalDesign(); const theme = useIOTheme(); + const { dynamicFontScale } = useIOFontDynamicScale(); const { themeType } = useIOThemeContext(); const bgOpacityDarkMode = 0.2; @@ -237,12 +254,19 @@ export const Badge = ({ text, outline = false, variant, testID }: Badge) => { const { background, foreground } = variantMap[variant]; + const dynamicStyle: ViewStyle = { + borderRadius: IOBadgeRadius * dynamicFontScale, + paddingHorizontal: IOBadgeHSpacing * dynamicFontScale, + paddingVertical: IOBadgeVSpacing * dynamicFontScale + }; + return ( { ]} > {title && ( <> - {/* Once we get 'gap' property, we can get rid of - these components */}
{title}
diff --git a/src/components/banner/__test__/__snapshots__/banner.test.tsx.snap b/src/components/banner/__test__/__snapshots__/banner.test.tsx.snap index 42e0e1d1..30adbaef 100644 --- a/src/components/banner/__test__/__snapshots__/banner.test.tsx.snap +++ b/src/components/banner/__test__/__snapshots__/banner.test.tsx.snap @@ -75,7 +75,7 @@ exports[`Test Banner Components - Experimental Enabled Banner Snapshot 1`] = ` ( ( @@ -156,6 +157,7 @@ export const ButtonLink = forwardRef( iconPosition === "end" && { flexDirection: "row-reverse" }, { columnGap: iconMargin }, disabled ? { opacity: DISABLED_OPACITY } : {}, + { columnGap: ICON_MARGIN }, /* Prevent Reanimated from overriding background colors if button is disabled */ !disabled && !reducedMotion && scaleAnimatedStyle @@ -164,6 +166,7 @@ export const ButtonLink = forwardRef( {icon && (!disabled ? ( ( /> ) : ( ( isExperimental && fullWidth && { paddingHorizontal: 16 }, buttonStylesLocal.buttonWithBorder, buttonStyles.buttonSizeDefault, + { columnGap: ICON_MARGIN }, iconPosition === "end" && { flexDirection: "row-reverse" }, disabled ? { @@ -320,25 +321,23 @@ export const ButtonOutline = forwardRef( !disabled && pressedAnimationStyle ]} > - {icon && ( - <> - {!disabled ? ( - - ) : ( - - )} - - - )} + {icon && + (!disabled ? ( + + ) : ( + + ))} ( {icon && ( - <> - {/* If 'iconPosition' is set to 'end', we use - reverse flex property to invert the position */} - - {/* Once we have support for 'gap' property, - we can get rid of that spacer */} - - + )} {!disabled ? ( ) : ( ; const checkBoxRadius: number = 5; const styles = StyleSheet.create({ - checkBoxWrapper: { - width: IOSelectionTickVisualParams.size, - height: IOSelectionTickVisualParams.size - }, checkboxBorder: { + borderWidth: IOSelectionTickVisualParams.borderWidth, + borderCurve: "continuous", position: "absolute", left: 0, - top: 0, - width: IOSelectionTickVisualParams.size, - height: IOSelectionTickVisualParams.size, - borderWidth: IOSelectionTickVisualParams.borderWidth, - borderRadius: checkBoxRadius, - borderCurve: "continuous" + top: 0 }, checkBoxSquare: { + borderCurve: "continuous", position: "absolute", left: 0, - top: 0, - width: IOSelectionTickVisualParams.size, - height: IOSelectionTickVisualParams.size, - borderRadius: checkBoxRadius, - borderCurve: "continuous" + top: 0 } }); @@ -55,7 +53,13 @@ const styles = StyleSheet.create({ * An animated checkbox. This can be used to implement a * standard {@link CheckBox} or other composite components. */ -export const AnimatedCheckbox = ({ checked, onPress, disabled }: OwnProps) => { +export const AnimatedCheckbox = ({ + size, + checked, + onPress, + disabled +}: OwnProps) => { + const { dynamicFontScale } = useIOFontDynamicScale(); const isChecked = checked ?? false; const { isExperimental } = useIOExperimentalDesign(); @@ -80,6 +84,17 @@ export const AnimatedCheckbox = ({ checked, onPress, disabled }: OwnProps) => { const squareAnimationProgress = useSharedValue(checked ? 1 : 0); const tickAnimationProgress = useSharedValue(checked ? 1 : 0); + const checkboxSizeStyle: ViewStyle = { + width: size, + height: size, + borderRadius: checkBoxRadius * dynamicFontScale + }; + + const checkboxWrapperSizeStyle: ViewStyle = { + width: size, + height: size + }; + useEffect(() => { // eslint-disable-next-line functional/immutable-data squareAnimationProgress.value = withSpring( @@ -108,12 +123,13 @@ export const AnimatedCheckbox = ({ checked, onPress, disabled }: OwnProps) => { accessible={false} disabled={disabled} onPress={onPress} - style={styles.checkBoxWrapper} + style={checkboxWrapperSizeStyle} testID="AnimatedCheckboxInput" > { { {isChecked && ( diff --git a/src/components/checkbox/CheckboxLabel.tsx b/src/components/checkbox/CheckboxLabel.tsx index d0630ca0..4d342b1c 100644 --- a/src/components/checkbox/CheckboxLabel.tsx +++ b/src/components/checkbox/CheckboxLabel.tsx @@ -1,10 +1,14 @@ import * as React from "react"; -import { useState } from "react"; +import { ComponentProps, useState } from "react"; import { Pressable, View } from "react-native"; -import { useIOTheme } from "../../core"; -import { IOStyles } from "../../core/IOStyles"; +import { + IOSelectionTickVisualParams, + IOSpacingScale, + useIOTheme +} from "../../core"; import { triggerHaptic } from "../../functions/haptic-feedback/hapticFeedback"; -import { HSpacer } from "../spacer/Spacer"; +import { useIOFontDynamicScale } from "../../utils/accessibility"; +import { HStack } from "../stack"; import { H6 } from "../typography/H6"; import { AnimatedCheckbox } from "./AnimatedCheckbox"; @@ -15,12 +19,13 @@ type Props = { }; const DISABLED_OPACITY = 0.5; +const CHECKBOX_MARGIN: IOSpacingScale = 8; // disabled: the component is no longer touchable // onPress: type OwnProps = Props & - Pick, "disabled" | "checked"> & - Pick, "onPress">; + Pick, "disabled" | "checked"> & + Pick, "onPress">; /** * A checkbox with the automatic state management that uses a {@link AnimatedCheckBox} @@ -36,6 +41,7 @@ export const CheckboxLabel = ({ onValueChange }: OwnProps) => { const theme = useIOTheme(); + const { dynamicFontScale } = useIOFontDynamicScale(); const [toggleValue, setToggleValue] = useState(checked ?? false); @@ -65,7 +71,11 @@ export const CheckboxLabel = ({ // inheritance on Android needsOffscreenAlphaCompositing={true} > - + - + -
{label}
-
+ ); }; diff --git a/src/components/common/AnimatedTick.tsx b/src/components/common/AnimatedTick.tsx index 4e8094dd..f3f4a06d 100644 --- a/src/components/common/AnimatedTick.tsx +++ b/src/components/common/AnimatedTick.tsx @@ -1,5 +1,6 @@ import React, { useState } from "react"; import Animated, { + SharedValue, useAnimatedProps, useAnimatedRef } from "react-native-reanimated"; @@ -8,7 +9,8 @@ import Svg, { Path, PathProps } from "react-native-svg"; const AnimatedPath = Animated.createAnimatedComponent(Path); interface AnimatedTickProps extends PathProps { - progress: Animated.SharedValue; + size?: number; + progress: SharedValue; onLayout?: () => void; } @@ -20,7 +22,11 @@ const TickSVGPath = "m7 12 4 4 7-7"; * It comes without any state logic. * */ -export const AnimatedTick = ({ progress, ...pathProps }: AnimatedTickProps) => { +export const AnimatedTick = ({ + size, + progress, + ...pathProps +}: AnimatedTickProps) => { const [length, setLength] = useState(0); const ref = useAnimatedRef(); @@ -38,7 +44,7 @@ export const AnimatedTick = ({ progress, ...pathProps }: AnimatedTickProps) => { }; return ( - + { + const { dynamicFontScale } = useIOFontDynamicScale(); const logos = isExtended ? IOPaymentExtLogos : IOPaymentLogos; if (!brand) { - return ; + return ( + + ); } const findCase = findFirstCaseInsensitive(logos, brand); if (!findCase) { - return ; + return ( + + ); } return isExtended ? ( - + ) : ( - + ); }; diff --git a/src/components/featureInfo/FeatureInfo.tsx b/src/components/featureInfo/FeatureInfo.tsx index 68c5de65..503f05cd 100644 --- a/src/components/featureInfo/FeatureInfo.tsx +++ b/src/components/featureInfo/FeatureInfo.tsx @@ -1,8 +1,8 @@ import React, { ReactNode } from "react"; -import { GestureResponderEvent, View } from "react-native"; +import { GestureResponderEvent } from "react-native"; import { BodySmall, - HSpacer, + HStack, IOIconSizeScale, IOIcons, IOPictogramSizeScale, @@ -11,7 +11,7 @@ import { Pictogram, VStack } from "../../components"; -import { IOStyles, useIOTheme } from "../../core"; +import { useIOTheme } from "../../core"; type PartialFeatureInfo = { // Necessary to render main body with different formatting @@ -57,24 +57,28 @@ export const FeatureInfo = ({ const theme = useIOTheme(); return ( - + {iconName && ( )} {pictogramName && ( - + )} - - + {renderNode(body)} {action && ( )} - + ); }; diff --git a/src/components/icons/Icon.tsx b/src/components/icons/Icon.tsx index b3c564b9..ad96a4c0 100644 --- a/src/components/icons/Icon.tsx +++ b/src/components/icons/Icon.tsx @@ -1,6 +1,7 @@ import React from "react"; import { ColorValue } from "react-native"; import { IOColors } from "../../core/IOColors"; +import { useIOFontDynamicScale } from "../../utils/accessibility"; /* Icons */ import IconAbacus from "./svg/IconAbacus"; @@ -51,6 +52,7 @@ import IconChevronRight from "./svg/IconChevronRight"; import IconChevronRightListItem from "./svg/IconChevronRightListItem"; import IconChevronTop from "./svg/IconChevronTop"; import IconCie from "./svg/IconCie"; +import IconCieLetter from "./svg/IconCieLetter"; import IconCloseLarge from "./svg/IconCloseLarge"; import IconCloseMedium from "./svg/IconCloseMedium"; import IconCloseSmall from "./svg/IconCloseSmall"; @@ -200,7 +202,6 @@ import LegIconCheckOff from "./svg/LegIconCheckOff"; import LegIconCheckOn from "./svg/LegIconCheckOn"; import LegIconRadioOff from "./svg/LegIconRadioOff"; import LegIconRadioOn from "./svg/LegIconRadioOn"; -import IconCieLetter from "./svg/IconCieLetter"; export const IOIcons = { spid: IconSpid, @@ -406,10 +407,7 @@ export const IOIcons = { export type IOIcons = keyof typeof IOIcons; -/* The following values should be deleted: 12, 30 */ -/* 96 is too big for an icon, it should be replaced -with a Pictogram instead */ -export type IOIconSizeScale = 12 | 16 | 20 | 24 | 30 | 32 | 48 | 96; +export type IOIconSizeScale = 16 | 20 | 24 | 32 | 48; /* Sizes used exclusively for the Checkbox component */ export type IOIconSizeScaleCheckbox = 14 | 18; @@ -420,6 +418,7 @@ export type IOIconsProps = { testID?: string; accessible?: boolean; accessibilityLabel?: string; + allowFontScaling?: boolean; }; /* @@ -432,15 +431,23 @@ export const Icon = ({ size = 24, accessible = false, accessibilityLabel = "", + allowFontScaling = false, ...props }: IOIconsProps) => { + const { dynamicFontScale } = useIOFontDynamicScale(); + const IconElement = IOIcons[name]; const isAccessible = accessible && accessibilityLabel.trim().length > 0; + const iconSize = + allowFontScaling && typeof size === "number" + ? size * dynamicFontScale + : size; + return ( { + const { dynamicFontScale } = useIOFontDynamicScale(); + const IconElement = IOIcons[name]; + const iconSize = + allowFontScaling && typeof size === "number" + ? size * dynamicFontScale + : size; + return (
- + {type === "threeActions" && ( - <> - - {/* Ideally, with the "gap" flex property, - we can get rid of these ugly constructs */} - - + )} {(type === "twoActions" || type === "threeActions") && ( - <> - - {/* Same as above */} - - + )} {type !== "base" && ( )} - +
); diff --git a/src/components/layout/HeaderSecondLevel.tsx b/src/components/layout/HeaderSecondLevel.tsx index 3a9f75ed..f4edfd07 100644 --- a/src/components/layout/HeaderSecondLevel.tsx +++ b/src/components/layout/HeaderSecondLevel.tsx @@ -24,7 +24,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { IOColors, IOSpringValues, - IOStyles, IOVisualCostants, alertEdgeToEdgeInsetTransitionConfig, hexToRgba, @@ -37,6 +36,7 @@ import type { IOSpacer, IOSpacingScale } from "../../core/IOSpacing"; import { WithTestID } from "../../utils/types"; import IconButton from "../buttons/IconButton"; import { HSpacer } from "../spacer"; +import { HStack } from "../stack"; import { IOText } from "../typography"; import { HeaderActionProps } from "./common"; @@ -310,28 +310,19 @@ export const HeaderSecondLevel = ({ {title}
- + {type === "threeActions" && ( - <> - - {/* Same as above */} - - + )} {(type === "twoActions" || type === "threeActions") && ( - <> - - {/* Ideally, with the "gap" flex property, - we can get rid of these ugly constructs */} - - + )} {type !== "base" ? ( ) : ( )} - +
); diff --git a/src/components/listitems/ListItemAction.tsx b/src/components/listitems/ListItemAction.tsx index d1493c14..df6aa941 100644 --- a/src/components/listitems/ListItemAction.tsx +++ b/src/components/listitems/ListItemAction.tsx @@ -8,6 +8,7 @@ import { useIOTheme } from "../../core"; import { useListItemAnimation } from "../../hooks"; +import { useIOFontDynamicScale } from "../../utils/accessibility"; import { WithTestID } from "../../utils/types"; import { AnimatedIcon, IOIcons } from "../icons"; import { ButtonText } from "../typography/ButtonText"; @@ -37,6 +38,8 @@ export const ListItemAction = ({ const theme = useIOTheme(); + const { dynamicFontScale, spacingScaleMultiplier } = useIOFontDynamicScale(); + const listItemAccessibilityLabel = useMemo( () => (accessibilityLabel ? accessibilityLabel : `${label}`), [label, accessibilityLabel] @@ -68,16 +71,24 @@ export const ListItemAction = ({ accessibilityElementsHidden > {icon && ( - - - + )} {label} diff --git a/src/components/listitems/ListItemAmount.tsx b/src/components/listitems/ListItemAmount.tsx index 24f7c638..6cf1eb14 100644 --- a/src/components/listitems/ListItemAmount.tsx +++ b/src/components/listitems/ListItemAmount.tsx @@ -7,9 +7,9 @@ import { IOStyles, useIOTheme } from "../../core"; +import { useIOFontDynamicScale } from "../../utils/accessibility"; import { WithTestID } from "../../utils/types"; import { IOIcons, Icon } from "../icons"; -import { HSpacer } from "../spacer"; import { H3, H6 } from "../typography"; type ValueProps = ComponentProps; @@ -42,6 +42,8 @@ export const ListItemAmount = ({ testID }: ListItemAmount) => { const theme = useIOTheme(); + const { dynamicFontScale, spacingScaleMultiplier, hugeFontEnabled } = + useIOFontDynamicScale(); const listItemAccessibilityLabel = useMemo( () => (accessibilityLabel ? accessibilityLabel : `${label}`), @@ -65,18 +67,21 @@ export const ListItemAmount = ({ accessible accessibilityLabel={listItemAccessibilityLabel} > - - {iconName && ( - - - + + {iconName && !hugeFontEnabled && ( + )} {itemInfoTextComponent} -

{ + const { dynamicFontScale, spacingScaleMultiplier, hugeFontEnabled } = + useIOFontDynamicScale(); + const [toggleValue, setToggleValue] = useState(selected ?? false); const { onPressIn, onPressOut, scaleAnimatedStyle, backgroundAnimatedStyle } = useListItemAnimation(); @@ -98,19 +103,25 @@ export const ListItemCheckbox = ({ > - - {icon && ( - - - + + {icon && !hugeFontEnabled && ( + )}
{value} @@ -122,7 +133,10 @@ export const ListItemCheckbox = ({ accessibilityElementsHidden importantForAccessibility="no-hide-descendants" > - + {description && ( diff --git a/src/components/listitems/ListItemHeader.tsx b/src/components/listitems/ListItemHeader.tsx index 5457b569..84ba3cc2 100644 --- a/src/components/listitems/ListItemHeader.tsx +++ b/src/components/listitems/ListItemHeader.tsx @@ -7,6 +7,7 @@ import { IOStyles, useIOTheme } from "../../core"; +import { useIOFontDynamicScale } from "../../utils/accessibility"; import { WithTestID } from "../../utils/types"; import { Badge } from "../badge"; import { ButtonLink, IconButton } from "../buttons"; @@ -61,6 +62,9 @@ export const ListItemHeader = ({ }: ListItemHeader) => { const theme = useIOTheme(); + const { dynamicFontScale, spacingScaleMultiplier, hugeFontEnabled } = + useIOFontDynamicScale(); + const listItemAccessibilityLabel = useMemo( () => (accessibilityLabel ? accessibilityLabel : `${label}`), [label, accessibilityLabel] @@ -129,9 +133,15 @@ export const ListItemHeader = ({ style={IOListItemStyles.listItemInner} importantForAccessibility={endElement ? "auto" : "no-hide-descendants"} > - {iconName && ( - + {iconName && !hugeFontEnabled && ( + { const theme = useIOTheme(); + const { dynamicFontScale, spacingScaleMultiplier, hugeFontEnabled } = + useIOFontDynamicScale(); + const componentValueToAccessibility = useMemo( () => (typeof value === "string" ? value : ""), [value] @@ -140,30 +144,33 @@ export const ListItemInfo = ({ accessibilityLabel={listItemAccessibilityLabel} accessibilityRole={accessibilityRole} > - - {icon && ( - - - + + {icon && !hugeFontEnabled && ( + )} {paymentLogoIcon && ( - - - + )} {itemInfoTextComponent} - {endElement && ( - - {listItemInfoAction()} - - )} + {endElement && {listItemInfoAction()}} ); diff --git a/src/components/listitems/ListItemInfoCopy.tsx b/src/components/listitems/ListItemInfoCopy.tsx index cbd440a7..1e9d6315 100644 --- a/src/components/listitems/ListItemInfoCopy.tsx +++ b/src/components/listitems/ListItemInfoCopy.tsx @@ -8,6 +8,7 @@ import { useIOTheme } from "../../core"; import { useListItemAnimation } from "../../hooks"; +import { useIOFontDynamicScale } from "../../utils/accessibility"; import { WithTestID } from "../../utils/types"; import { IOIcons, Icon } from "../icons"; import { BodySmall, H6 } from "../typography"; @@ -38,6 +39,9 @@ export const ListItemInfoCopy = ({ const { onPressIn, onPressOut, scaleAnimatedStyle, backgroundAnimatedStyle } = useListItemAnimation(); + const { dynamicFontScale, spacingScaleMultiplier, hugeFontEnabled } = + useIOFontDynamicScale(); + const componentValueToAccessibility = useMemo( () => (typeof value === "string" ? value : ""), [value] @@ -87,25 +91,32 @@ export const ListItemInfoCopy = ({ style={[IOListItemStyles.listItem, backgroundAnimatedStyle]} > - {icon && ( - - - - )} - {listItemInfoCopyContent} - + {icon && !hugeFontEnabled && ( - + )} + {listItemInfoCopyContent} + diff --git a/src/components/listitems/ListItemNav.tsx b/src/components/listitems/ListItemNav.tsx index 4ca92f27..17bbc409 100644 --- a/src/components/listitems/ListItemNav.tsx +++ b/src/components/listitems/ListItemNav.tsx @@ -1,11 +1,5 @@ -import React, { ComponentProps, ReactNode } from "react"; -import { - GestureResponderEvent, - Image, - Pressable, - StyleSheet, - View -} from "react-native"; +import React, { ComponentProps } from "react"; +import { GestureResponderEvent, Image, Pressable, View } from "react-native"; import Animated from "react-native-reanimated"; import { IOColors, @@ -16,13 +10,14 @@ import { useIOTheme } from "../../core"; import { useListItemAnimation } from "../../hooks"; +import { useIOFontDynamicScale } from "../../utils/accessibility"; import { WithTestID } from "../../utils/types"; import { Avatar } from "../avatar"; import { Badge } from "../badge"; import { IOIcons, Icon } from "../icons"; import { LoadingSpinner } from "../loadingSpinner"; import { HSpacer, VSpacer } from "../spacer"; -import { Caption, H6, BodySmall } from "../typography"; +import { BodySmall, Caption, H6 } from "../typography"; type ListItemTopElementProps = | { @@ -80,13 +75,6 @@ export type ListItemNavGraphicProps = export type ListItemNav = ListItemNavPartialProps & ListItemNavGraphicProps; -const styles = StyleSheet.create({ - paymentLogoSize: { - width: IOSelectionListItemVisualParams.iconSize, - height: IOSelectionListItemVisualParams.iconSize - } -}); - export const ListItemNav = ({ value, description, @@ -107,11 +95,8 @@ export const ListItemNav = ({ useListItemAnimation(); const theme = useIOTheme(); - const withMargin = (GraphicalAsset: ReactNode) => ( - - {GraphicalAsset} - - ); + const { dynamicFontScale, spacingScaleMultiplier, hugeFontEnabled } = + useIOFontDynamicScale(); const listItemNavContent = ( <> @@ -128,7 +113,12 @@ export const ListItemNav = ({ {topElement.dateValue && ( <> - + {topElement.dateValue} @@ -186,35 +176,49 @@ export const ListItemNav = ({ style={[IOListItemStyles.listItem, backgroundAnimatedStyle]} > {/* Possibile graphical assets - Icon - Image URL (for payment logos) - Avatar */} - {icon && - withMargin( - - )} - {paymentLogoUri && - withMargin( - - )} - {avatar && withMargin()} + {icon && !hugeFontEnabled && ( + + )} + {paymentLogoUri && ( + + )} + {avatar && } {listItemNavContent} {loading && } {!loading && !hideChevron && ( ); + const iconColor = isExperimental ? theme["interactiveElem-default"] : "blue"; + return ( {!withoutIcon && ( - - - - )} - {listItemNavAlertContent} - - + )} + {listItemNavAlertContent} + + diff --git a/src/components/listitems/ListItemRadio.tsx b/src/components/listitems/ListItemRadio.tsx index 35d795ea..f2378e78 100644 --- a/src/components/listitems/ListItemRadio.tsx +++ b/src/components/listitems/ListItemRadio.tsx @@ -7,17 +7,19 @@ import Placeholder from "rn-placeholder"; import { IOSelectionListItemStyles, IOSelectionListItemVisualParams, + IOSelectionTickVisualParams, IOStyles, useIOTheme } from "../../core"; import { useListItemAnimation } from "../../hooks"; +import { useIOFontDynamicScale } from "../../utils/accessibility"; import { WithTestID } from "../../utils/types"; import { IOIcons, Icon } from "../icons"; import { IOLogoPaymentType, LogoPayment } from "../logos"; import { AnimatedRadio } from "../radio/AnimatedRadio"; import { HSpacer, VSpacer } from "../spacer"; -import { H6, BodySmall } from "../typography"; import { VStack } from "../stack"; +import { BodySmall, H6 } from "../typography"; type ListItemRadioGraphicProps = | { icon?: never; paymentLogo: IOLogoPaymentType; uri?: never } @@ -81,6 +83,8 @@ export const ListItemRadio = ({ testID }: ListItemRadioProps) => { const [toggleValue, setToggleValue] = useState(selected ?? false); + const { dynamicFontScale, spacingScaleMultiplier, hugeFontEnabled } = + useIOFontDynamicScale(); const { onPressIn, onPressOut, scaleAnimatedStyle, backgroundAnimatedStyle } = useListItemAnimation(); const theme = useIOTheme(); @@ -139,7 +143,10 @@ export const ListItemRadio = ({ - + @@ -177,37 +184,41 @@ export const ListItemRadio = ({ > - - {startImage && ( - - {/* icon or paymentLogo props are mutually exclusive */} - {startImage.icon && ( - - )} - {startImage.uri && ( - - )} - {startImage.paymentLogo && ( - - )} + + {startImage?.icon && !hugeFontEnabled && ( + + )} + {startImage?.uri && ( + + + + )} + {startImage?.paymentLogo && ( + + )} -
{value}
@@ -218,7 +229,10 @@ export const ListItemRadio = ({ accessibilityElementsHidden importantForAccessibility="no-hide-descendants" > - +
{description && ( diff --git a/src/components/listitems/ListItemRadioWithAmount.tsx b/src/components/listitems/ListItemRadioWithAmount.tsx index bd18bd8d..2b8a9577 100644 --- a/src/components/listitems/ListItemRadioWithAmount.tsx +++ b/src/components/listitems/ListItemRadioWithAmount.tsx @@ -1,7 +1,8 @@ import * as React from "react"; import { View } from "react-native"; import RNReactNativeHapticFeedback from "react-native-haptic-feedback"; -import { IOColors, useIOTheme } from "../../core"; +import { IOColors, IOSelectionTickVisualParams, useIOTheme } from "../../core"; +import { useIOFontDynamicScale } from "../../utils/accessibility"; import { Icon } from "../icons"; import { AnimatedRadio } from "../radio/AnimatedRadio"; import { HStack } from "../stack"; @@ -33,6 +34,7 @@ export const ListItemRadioWithAmount = ({ suggestReason, formattedAmountString }: ListItemRadioWithAmountProps) => { + const { dynamicFontScale } = useIOFontDynamicScale(); const [toggleValue, setToggleValue] = React.useState(selected ?? false); const pressHandler = () => { @@ -77,7 +79,10 @@ export const ListItemRadioWithAmount = ({
{formattedAmountString}
- + ); diff --git a/src/components/listitems/ListItemSwitch.tsx b/src/components/listitems/ListItemSwitch.tsx index 7191041f..2418eab9 100644 --- a/src/components/listitems/ListItemSwitch.tsx +++ b/src/components/listitems/ListItemSwitch.tsx @@ -1,10 +1,11 @@ -import React, { useMemo } from "react"; +import React, { ComponentProps, useMemo } from "react"; import { GestureResponderEvent, Platform, Switch, View } from "react-native"; import { IOSelectionListItemStyles, IOSelectionListItemVisualParams, useIOTheme } from "../../core"; +import { useIOFontDynamicScale } from "../../utils/accessibility"; import { WithTestID } from "../../utils/types"; import { Badge } from "../badge"; import { IOIcons, Icon } from "../icons"; @@ -42,7 +43,7 @@ const ESTIMATED_SWITCH_HEIGHT: number = 32; export type ListItemSwitchProps = PartialProps & ListItemSwitchGraphicProps & - Pick, "value" | "disabled">; + Pick, "value" | "disabled">; export const ListItemSwitch = React.memo( ({ @@ -60,6 +61,8 @@ export const ListItemSwitch = React.memo( testID }: ListItemSwitchProps) => { const theme = useIOTheme(); + const { dynamicFontScale, spacingScaleMultiplier, hugeFontEnabled } = + useIOFontDynamicScale(); // If we have a badge or we are loading, we can't render the switch // this affects the accessibility tree and the rendering of the component @@ -92,7 +95,11 @@ export const ListItemSwitch = React.memo( style={{ flex: 1, flexDirection: "row", - alignItems: "center" + alignItems: icon ? "flex-start" : "center", + columnGap: + IOSelectionListItemVisualParams.iconMargin * + dynamicFontScale * + spacingScaleMultiplier }} accessible={!canRenderSwitch} {...Platform.select({ @@ -104,32 +111,19 @@ export const ListItemSwitch = React.memo( })} accessibilityState={{ disabled }} > - {icon && ( - - - + {icon && !hugeFontEnabled && ( + )} {paymentLogo && ( - - - + )}
( <> @@ -168,6 +170,7 @@ export const ListItemTransaction = ({ )} {showChevron && ( - - - - - - - + fillRule={0} + propList={ + [ + "fill", + "fillRule", + ] + } + /> + + @@ -527,6 +530,9 @@ exports[`Test List Item Components - Experimental Enabled ListItemNav Snapshot "flexDirection": "row", "justifyContent": "space-between", }, + { + "columnGap": 12, + }, { "transform": [ { @@ -547,7 +553,7 @@ exports[`Test List Item Components - Experimental Enabled ListItemNav Snapshot - - - - - - - + fillRule={0} + propList={ + [ + "fill", + "fillRule", + ] + } + /> + + - - - - - - - + fillRule={0} + propList={ + [ + "fill", + "fillRule", + ] + } + /> + + @@ -947,7 +940,7 @@ exports[`Test List Item Components - Experimental Enabled ListItemRadioWithAmou - - - - - - - + fillRule={0} + propList={ + [ + "fill", + "fillRule", + ] + } + /> + + @@ -2196,6 +2202,9 @@ exports[`Test List Item Components ListItemNav Snapshot 1`] = ` "flexDirection": "row", "justifyContent": "space-between", }, + { + "columnGap": 12, + }, { "transform": [ { @@ -2216,7 +2225,7 @@ exports[`Test List Item Components ListItemNav Snapshot 1`] = ` - - - - - - - + fillRule={0} + propList={ + [ + "fill", + "fillRule", + ] + } + /> + + - - - - - - - + fillRule={0} + propList={ + [ + "fill", + "fillRule", + ] + } + /> + + @@ -2616,7 +2612,7 @@ exports[`Test List Item Components ListItemRadioWithAmount Snapshot 1`] = ` ) => { const theme = useIOTheme(); + const { hugeFontEnabled } = useIOFontDynamicScale(); - const iconComponent = icon && ( + const iconComponent = icon && !hugeFontEnabled && ( ); diff --git a/src/components/modules/ModuleNavigation.tsx b/src/components/modules/ModuleNavigation.tsx index 7ca00651..2b499f1e 100644 --- a/src/components/modules/ModuleNavigation.tsx +++ b/src/components/modules/ModuleNavigation.tsx @@ -19,6 +19,7 @@ import { Badge } from "../badge"; import { IOIcons, Icon } from "../icons"; import { HStack, VStack } from "../stack"; import { LabelMini, BodySmall } from "../typography"; +import { useIOFontDynamicScale } from "../../utils/accessibility"; import { ModuleStatic } from "./ModuleStatic"; import { PressableModuleBase, @@ -45,6 +46,7 @@ type ModuleNavigationProps = LoadingProps | BaseProps; export const ModuleNavigation = (props: WithTestID) => { const theme = useIOTheme(); + const { hugeFontEnabled } = useIOFontDynamicScale(); if (props.isLoading) { return ; @@ -53,7 +55,7 @@ export const ModuleNavigation = (props: WithTestID) => { const { icon, image, title, subtitle, onPress, badge, ...pressableProps } = props; - const iconComponent = icon && ( + const iconComponent = icon && !hugeFontEnabled && ( { - const PictogramElement = IOPictograms[name]; const theme = useIOTheme(); + const { dynamicFontScale, spacingScaleMultiplier } = useIOFontDynamicScale(); + + const PictogramElement = IOPictograms[name]; + + const pictogramSize = + allowFontScaling && typeof size === "number" + ? size * dynamicFontScale * spacingScaleMultiplier + : size; const themeObj = useMemo(() => { switch (pictogramStyle) { @@ -243,7 +255,7 @@ export const Pictogram = ({ return ( @@ -356,11 +368,18 @@ export const PictogramBleed = ({ color = "aqua", size = 80, pictogramStyle = "default", + allowFontScaling = false, ...props }: IOPictogramsProps) => { + const theme = useIOTheme(); + const { dynamicFontScale, spacingScaleMultiplier } = useIOFontDynamicScale(); + const PictogramElement = IOPictogramsBleed[name as IOPictogramsBleed]; - const theme = useIOTheme(); + const pictogramSize = + allowFontScaling && typeof size === "number" + ? size * dynamicFontScale * spacingScaleMultiplier + : size; const themeObj = useMemo(() => { switch (pictogramStyle) { @@ -386,7 +405,7 @@ export const PictogramBleed = ({ return ( diff --git a/src/components/pictograms/types.ts b/src/components/pictograms/types.ts index 0ffb7a52..e8109e0d 100644 --- a/src/components/pictograms/types.ts +++ b/src/components/pictograms/types.ts @@ -1,9 +1,7 @@ import { ColorValue } from "react-native"; -export type IOPictogramSizeScale = 48 | 64 | 72 | 80 | 120 | 180; - export type SVGPictogramProps = { - size: IOPictogramSizeScale | "100%"; + size: number | "100%"; color: ColorValue; colorValues: Record; }; diff --git a/src/components/radio/AnimatedRadio.tsx b/src/components/radio/AnimatedRadio.tsx index 64e442e4..45ca820a 100644 --- a/src/components/radio/AnimatedRadio.tsx +++ b/src/components/radio/AnimatedRadio.tsx @@ -1,5 +1,11 @@ import React, { useEffect } from "react"; -import { Pressable, PressableProps, StyleSheet, View } from "react-native"; +import { + Pressable, + PressableProps, + StyleSheet, + View, + ViewStyle +} from "react-native"; import Animated, { Easing, interpolate, @@ -18,32 +24,23 @@ import { import { AnimatedTick } from "../common/AnimatedTick"; type Props = { + size: number; checked?: boolean; }; type OwnProps = Props & Pick; const styles = StyleSheet.create({ - radioWrapper: { - width: IOSelectionTickVisualParams.size, - height: IOSelectionTickVisualParams.size - }, radioBorder: { position: "absolute", left: 0, top: 0, - width: IOSelectionTickVisualParams.size, - height: IOSelectionTickVisualParams.size, - borderWidth: IOSelectionTickVisualParams.borderWidth, - borderRadius: IOSelectionTickVisualParams.size / 2 + borderWidth: IOSelectionTickVisualParams.borderWidth }, radioCircle: { position: "absolute", left: 0, - top: 0, - width: IOSelectionTickVisualParams.size, - height: IOSelectionTickVisualParams.size, - borderRadius: IOSelectionTickVisualParams.size / 2 + top: 0 } }); @@ -51,7 +48,12 @@ const styles = StyleSheet.create({ * An animated checkbox. This can be used to implement a * standard {@link CheckBox} or other composite components. */ -export const AnimatedRadio = ({ checked, onPress, disabled }: OwnProps) => { +export const AnimatedRadio = ({ + size, + checked, + onPress, + disabled +}: OwnProps) => { const isChecked = checked ?? false; const { isExperimental } = useIOExperimentalDesign(); @@ -76,6 +78,17 @@ export const AnimatedRadio = ({ checked, onPress, disabled }: OwnProps) => { const circleAnimationProgress = useSharedValue(checked ? 1 : 0); const tickAnimationProgress = useSharedValue(checked ? 1 : 0); + const radioButtonSizeStyle: ViewStyle = { + width: size, + height: size, + borderRadius: size / 2 + }; + + const radioButtonWrapperSizeStyle: ViewStyle = { + width: size, + height: size + }; + useEffect(() => { // eslint-disable-next-line functional/immutable-data circleAnimationProgress.value = withSpring( @@ -105,11 +118,12 @@ export const AnimatedRadio = ({ checked, onPress, disabled }: OwnProps) => { disabled={disabled} testID="AnimatedRadioInput" onPress={onPress} - style={styles.radioWrapper} + style={radioButtonWrapperSizeStyle} > { { {isChecked && ( diff --git a/src/components/radio/RadioButtonLabel.tsx b/src/components/radio/RadioButtonLabel.tsx index 861768be..28bc422d 100644 --- a/src/components/radio/RadioButtonLabel.tsx +++ b/src/components/radio/RadioButtonLabel.tsx @@ -1,10 +1,10 @@ import * as React from "react"; -import { useState } from "react"; +import { ComponentProps, useState } from "react"; import { Pressable, View } from "react-native"; -import { useIOTheme } from "../../core"; +import { IOSelectionTickVisualParams, useIOTheme } from "../../core"; import { IOStyles } from "../../core/IOStyles"; import { triggerHaptic } from "../../functions/haptic-feedback/hapticFeedback"; -import { HSpacer } from "../spacer/Spacer"; +import { useIOFontDynamicScale } from "../../utils/accessibility"; import { H6 } from "../typography/H6"; import { AnimatedRadio } from "./AnimatedRadio"; @@ -17,9 +17,9 @@ type Props = { const DISABLED_OPACITY = 0.5; type RadioButtonLabelProps = Props & - Pick, "disabled" | "checked"> & + Pick, "disabled" | "checked"> & Pick< - React.ComponentProps, + ComponentProps, "onPress" | "accessibilityLabel" | "accessibilityHint" >; @@ -38,6 +38,7 @@ export const RadioButtonLabel = ({ accessibilityLabel, accessibilityHint }: RadioButtonLabelProps) => { + const { dynamicFontScale, spacingScaleMultiplier } = useIOFontDynamicScale(); const theme = useIOTheme(); const [toggleValue, setToggleValue] = useState(checked ?? false); @@ -70,7 +71,12 @@ export const RadioButtonLabel = ({ - + -
{label}
diff --git a/src/components/stack/Stack.tsx b/src/components/stack/Stack.tsx index 1a639809..18804a14 100644 --- a/src/components/stack/Stack.tsx +++ b/src/components/stack/Stack.tsx @@ -1,6 +1,7 @@ import React, { PropsWithChildren } from "react"; import { View, ViewProps, ViewStyle } from "react-native"; import { IOSpacer } from "../../core"; +import { useIOFontDynamicScale } from "../../utils/accessibility"; type AllowedStyleProps = Pick< ViewStyle, @@ -15,6 +16,7 @@ type A11YRelatedProps = Pick< type Stack = PropsWithChildren<{ space?: IOSpacer; style?: AllowedStyleProps; + allowScaleSpacing?: boolean; }> & A11YRelatedProps; @@ -22,30 +24,39 @@ type BaseStack = Stack & { orientation: "vertical" | "horizontal"; }; +const DEFAULT_SPACING_VALUE: IOSpacer = 16; + /** Horizontal Stack component @param {IOSpacer} space */ const Stack = ({ - space, + space = DEFAULT_SPACING_VALUE, style, orientation = "vertical", + allowScaleSpacing, children, ...props -}: BaseStack) => ( - - {children} - -); +}: BaseStack) => { + const { dynamicFontScale, spacingScaleMultiplier } = useIOFontDynamicScale(); + + return ( + + {children} + + ); +}; export const HStack = ({ children, ...props }: Stack) => ( diff --git a/src/components/tag/Tag.tsx b/src/components/tag/Tag.tsx index 5e7baa80..5a96cfc6 100644 --- a/src/components/tag/Tag.tsx +++ b/src/components/tag/Tag.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Platform, StyleSheet, View } from "react-native"; +import { Platform, StyleSheet, View, ViewStyle } from "react-native"; import { IOColors, IOTagRadius, @@ -13,6 +13,7 @@ import { IOTagHSpacing, IOTagVSpacing } from "../../core/IOSpacing"; +import { useIOFontDynamicScale } from "../../utils/accessibility"; import { WithTestID } from "../../utils/types"; import { IOIconSizeScale, IOIcons, Icon } from "../icons"; import { IOText } from "../typography"; @@ -62,7 +63,9 @@ export type Tag = TextProps & { forceLightMode?: boolean } & WithTestID< variant: "custom"; icon: VariantProps; } - >; + > & { + allowFontScaling?: boolean; + }; const IOTagIconMargin: IOSpacingScale = 6; const IOTagIconSize: IOIconSizeScale = 16; @@ -79,16 +82,17 @@ const styles = StyleSheet.create({ } }), borderWidth: 1, + borderCurve: "continuous" + }, + tagStatic: { borderRadius: IOTagRadius, borderCurve: "continuous", paddingHorizontal: IOTagHSpacing, - paddingVertical: IOTagVSpacing + paddingVertical: IOTagVSpacing, + columnGap: IOTagIconMargin }, iconWrapper: { flexShrink: 1 - }, - spacer: { - width: IOTagIconMargin } }); @@ -151,9 +155,11 @@ export const Tag = ({ testID, icon, iconAccessibilityLabel, + allowFontScaling = true, forceLightMode = false }: Tag) => { const theme = useIOTheme(); + const { dynamicFontScale, spacingScaleMultiplier } = useIOFontDynamicScale(); const { isExperimental } = useIOExperimentalDesign(); const variantProps = getVariantProps(variant, icon); @@ -166,14 +172,26 @@ export const Tag = ({ ? IOColors[IOThemeLight["appBackground-primary"]] : IOColors[theme["appBackground-primary"]]; + const tagDynamic: ViewStyle = { + paddingHorizontal: IOTagHSpacing * dynamicFontScale, + paddingVertical: IOTagVSpacing * dynamicFontScale, + columnGap: IOTagIconMargin * dynamicFontScale * spacingScaleMultiplier, + borderRadius: IOTagRadius * dynamicFontScale + }; + return ( {variantProps && ( )} - {variantProps && text && } {text && ( ( /* Accessible typography based on the `fontScale` parameter */ const accessibleFontSizeProps: ComponentProps = { allowFontScaling: allowFontScaling ?? isExperimental, - maxFontSizeMultiplier: maxFontSizeMultiplier ?? 1.25 + maxFontSizeMultiplier: maxFontSizeMultiplier ?? IOFontSizeMultiplier }; return ( diff --git a/src/components/typography/__test__/__snapshots__/ComposedBodyFromArray.test.tsx.snap b/src/components/typography/__test__/__snapshots__/ComposedBodyFromArray.test.tsx.snap index 09b7cef5..dae775fc 100644 --- a/src/components/typography/__test__/__snapshots__/ComposedBodyFromArray.test.tsx.snap +++ b/src/components/typography/__test__/__snapshots__/ComposedBodyFromArray.test.tsx.snap @@ -4,7 +4,7 @@ exports[`ComposedBodyFromArray should match snapshot, empty body, auto text alig { return boldTextEnabled; }; + +/** + * Returns a font size multiplier based on the font scale of the device, + * but limited to the `IOFontSizeMultiplier` value. + * @returns number + */ +export const useIOFontDynamicScale = (): { + dynamicFontScale: number; + spacingScaleMultiplier: number; + hugeFontEnabled: boolean; +} => { + const { isExperimental } = useIOExperimentalDesign(); + const { fontScale } = useWindowDimensions(); + + const deviceFontScale = isExperimental ? fontScale : 1; + const hugeFontEnabled = deviceFontScale >= 1.35; + + const dynamicFontScale = Math.min(deviceFontScale, IOFontSizeMultiplier); + /* We make the spacing dynamic based on the font scale, but we multiply + this value to limit the amount of scaling applied to the spacing */ + const spacingScaleMultiplier = + dynamicFontScale <= IOFontSizeMultiplier ? 1 : 0.8; + + return { + hugeFontEnabled, + dynamicFontScale, + spacingScaleMultiplier + }; +}; diff --git a/src/utils/fonts.ts b/src/utils/fonts.ts index ed015e29..b6216b79 100644 --- a/src/utils/fonts.ts +++ b/src/utils/fonts.ts @@ -122,6 +122,7 @@ export const makeFontFamilyName = ( const defaultFont: IOFontFamily = "TitilliumSansPro"; const defaultWeight: IOFontWeight = "Regular"; const defaultFontSize: IOFontSize = 16; +export const IOFontSizeMultiplier = 1.5; /** * Return a {@link FontStyleObject} with the fields filled based on the platform (iOS or Android). diff --git a/stories/components/checkbox/AnimatedCheckbox.stories.ts b/stories/components/checkbox/AnimatedCheckbox.stories.ts index 4729ca6b..2d627168 100644 --- a/stories/components/checkbox/AnimatedCheckbox.stories.ts +++ b/stories/components/checkbox/AnimatedCheckbox.stories.ts @@ -20,6 +20,7 @@ type Story = StoryObj; // More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args export const Primary: Story = { args: { + size: 24, checked: false } };