Skip to content

Commit

Permalink
feat: add amount field component
Browse files Browse the repository at this point in the history
  • Loading branch information
tigranpetrossian committed Nov 26, 2024
1 parent 5b44a9f commit c81acb1
Show file tree
Hide file tree
Showing 8 changed files with 249 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { useState } from 'react';
import { NativeSyntheticEvent, TextLayoutEventData } from 'react-native';

import { Box, Text, TextProps, Theme } from '@leather.io/ui/native';

const maxFontSize = 44;
const lineHeightRatio = 1;

const commonTextProps: TextProps = {
variant: 'heading02',
fontVariant: ['tabular-nums'],
letterSpacing: 1,
};

interface TextMeasurementProxyProps {
value: string;
textProps: TextProps;
onFontSizeChange(fontSize: number): void;
}

// The Text component's `adjustFontSizeTofit` is not animatable. It also causes text to shift
// due to dynamic positioning issue: https://github.com/facebook/react-native/issues/29507
// Use an invisible placeholder to extract the dynamic font size set by `adjustFontSizeToFit`
// as the text changes during typing.
function TextMeasurementProxy({ value, textProps, onFontSizeChange }: TextMeasurementProxyProps) {
const handleLayout = (event: NativeSyntheticEvent<TextLayoutEventData>) => {
const line = event.nativeEvent.lines[0];
if (line) {
onFontSizeChange(line.ascender);
}
};

return (
<Text
variant="heading02"
fontVariant={['tabular-nums']}
letterSpacing={1}
onTextLayout={handleLayout}
adjustsFontSizeToFit
numberOfLines={1}
aria-hidden={true}
style={{
flexGrow: 1,
opacity: 0,
}}
{...textProps}
>
{value}
</Text>
);
}

interface AmountFieldPrimaryValueProps {
color: keyof Theme['colors'];
children: string;
}

export function AmountFieldPrimaryValue({ children, color }: AmountFieldPrimaryValueProps) {
const [dynamicFontSize, setDynamicFontSize] = useState(maxFontSize);
// Maintain the relative lineHeight to prevent text shifting down as font size decreases
const staticLineHeight = maxFontSize * lineHeightRatio;
const dynamicLineHeight = dynamicFontSize * lineHeightRatio;

return (
<Box height={staticLineHeight} flexShrink={1}>
<Box flexDirection="row" position="absolute" top={3}>
{children.split('').map((character, index) => (
<Text
color={color}
key={index}
style={{ fontSize: dynamicFontSize, lineHeight: dynamicLineHeight }}
{...commonTextProps}
>
{character}
</Text>
))}
</Box>
<TextMeasurementProxy
value={children}
onFontSizeChange={setDynamicFontSize}
textProps={commonTextProps}
/>
</Box>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useTheme } from '@shopify/restyle';

import { ArrowTopBottomIcon, Box, Pressable, Text, Theme } from '@leather.io/ui/native';

interface AmountFieldSecondaryValueProps {
children: string;
onToggleCurrencyMode(): void;
}

export function AmountFieldSecondaryValue({
children,
onToggleCurrencyMode,
}: AmountFieldSecondaryValueProps) {
const theme = useTheme<Theme>();

return (
<Pressable hitSlop={16} onPress={onToggleCurrencyMode}>
<Box flexDirection="row" gap="1" alignItems="center">
<Text variant="label02" color="ink.text-subdued" numberOfLines={1} ellipsizeMode="clip">
{children}
</Text>
<ArrowTopBottomIcon color={theme.colors['ink.text-subdued']} variant="small" />
</Box>
</Pressable>
);
}
92 changes: 92 additions & 0 deletions apps/mobile/src/components/amount-field/amount-field.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { AmountSendMaxButton } from '@/components/amount-field/amount-send-max-button';

import { Box, Theme } from '@leather.io/ui/native';

import { AmountFieldPrimaryValue } from './amount-field-primary-value';
import { AmountFieldSecondaryValue } from './amount-field-secondary-value';

type CurrencyMode = 'crypto' | 'fiat'; // TODO: Should be moved into containing form types
type InternalState = 'initial' | 'active' | 'invalid';

interface AmountFieldProps {
inputValue: string;
invalid: boolean;
currencyMode: CurrencyMode;
onCurrencyModeChange(currencyMode: CurrencyMode, newInputValue: string): void;
onSetIsSendingMax(): void;
formatValue(value: string, mode: CurrencyMode): string;
calculateSecondaryValue(value: string, currencyMode: CurrencyMode): string;
}

export function AmountField({
inputValue,
currencyMode,
invalid,
onCurrencyModeChange,
onSetIsSendingMax,
calculateSecondaryValue,
formatValue,
}: AmountFieldProps) {
const state = evaluateInternalState({ inputValue, invalid });
const textColor = getTextColor(state);
const currency = {
primary: currencyMode,
secondary: currencyMode === 'crypto' ? 'fiat' : 'crypto',
} as const;
const amount = {
primary: inputValue,
secondary: calculateSecondaryValue(inputValue, currencyMode),
};

function onToggleCurrencyMode() {
onCurrencyModeChange(currency.secondary, amount.secondary);
}

return (
<Box
borderColor="ink.border-default"
borderBottomStartRadius="sm"
borderBottomEndRadius="sm"
borderWidth={1}
gap="2"
p="4"
>
<Box flexDirection="row" gap="4" justifyContent="space-between">
<AmountFieldPrimaryValue color={textColor}>
{formatValue(amount.primary, currency.primary)}
</AmountFieldPrimaryValue>
<AmountSendMaxButton onPress={onSetIsSendingMax} />
</Box>
<AmountFieldSecondaryValue onToggleCurrencyMode={onToggleCurrencyMode}>
{formatValue(amount.secondary, currency.secondary)}
</AmountFieldSecondaryValue>
</Box>
);
}

type EvaluateInternalStateParams = Pick<AmountFieldProps, 'invalid' | 'inputValue'>;

function evaluateInternalState({
invalid,
inputValue,
}: EvaluateInternalStateParams): InternalState {
if (invalid) {
return 'invalid';
}

if (inputValue !== '0') {
return 'active';
}

return 'initial';
}

function getTextColor(state: InternalState): keyof Theme['colors'] {
const colors: Record<InternalState, keyof Theme['colors']> = {
initial: 'ink.text-subdued',
active: 'ink.text-primary',
invalid: 'red.action-primary-default',
};

return colors[state];
}
20 changes: 20 additions & 0 deletions apps/mobile/src/components/amount-field/amount-send-max-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { t } from '@lingui/macro';

import { Pressable, Text } from '@leather.io/ui/native';

interface AmountSendMaxButtonProps {
onPress(): void;
}

export function AmountSendMaxButton({ onPress }: AmountSendMaxButtonProps) {
return (
<Pressable hitSlop={16} onPress={onPress}>
<Text variant="label02" textTransform="uppercase">
{t({
id: 'send_form.max_label',
message: 'Max',
})}
</Text>
</Pressable>
);
}
3 changes: 3 additions & 0 deletions packages/ui/src/assets/icons/arrow-top-bottom-16-16.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions packages/ui/src/assets/icons/arrow-top-bottom-24-24.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 19 additions & 0 deletions packages/ui/src/icons/arrow-top-bottom-icon.native.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Component, forwardRef } from 'react';

import ArrowTopBottomSmall from '../assets/icons/arrow-top-bottom-16-16.svg';
import ArrowTopBottom from '../assets/icons/arrow-top-bottom-24-24.svg';
import { Icon, IconProps } from './icon/icon.native';

export const ArrowTopBottomIcon = forwardRef<Component, IconProps>(({ variant, ...props }, ref) => {
if (variant === 'small')
return (
<Icon ref={ref} {...props}>
<ArrowTopBottomSmall />
</Icon>
);
return (
<Icon ref={ref} {...props}>
<ArrowTopBottom />
</Icon>
);
});
1 change: 1 addition & 0 deletions packages/ui/src/icons/index.native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export type { IconProps } from './icon/icon.native';

// Icons
export * from './arrow-left-icon.native';
export * from './arrow-top-bottom-icon.native';
export * from './arrow-out-of-box-icon.native';
export * from './arrows-repeat-left-right-icon.native';
export * from './arrow-rotate-clockwise-icon.native';
Expand Down

0 comments on commit c81acb1

Please sign in to comment.