diff --git a/packages/mobile/src/alpha/select/DefaultSelectControl.tsx b/packages/mobile/src/alpha/select/DefaultSelectControl.tsx index 5f8f4bc4ba..0ce1266471 100644 --- a/packages/mobile/src/alpha/select/DefaultSelectControl.tsx +++ b/packages/mobile/src/alpha/select/DefaultSelectControl.tsx @@ -21,6 +21,7 @@ import { isSelectOptionGroup } from './Select'; const LABEL_VARIANT_INSIDE_HEIGHT = 24; const COMPACT_HEIGHT = 40; const DEFAULT_HEIGHT = 56; +const selectControlBorderRadius = 400; const variantColor: Record = { foreground: 'fg', @@ -157,12 +158,18 @@ export const DefaultSelectControlComponent = memo( value, ]); - // Prop value doesn't have default value because it affects the color of the - // animated caret - const focusedVariant = useInputVariant(!!open, variant ?? 'foregroundMuted'); + const resolvedVariant = variant ?? 'foregroundMuted'; + const defaultFocusedVariant = useInputVariant(!!open, resolvedVariant); + const focusedVariant = useMemo(() => { + if (resolvedVariant === 'foreground' || resolvedVariant === 'foregroundMuted') { + return 'foreground'; + } + + return defaultFocusedVariant; + }, [defaultFocusedVariant, resolvedVariant]); const { borderFocusedStyle, borderUnfocusedStyle } = useInputBorderStyle( !!open, - variant ?? 'foregroundMuted', + resolvedVariant, focusedVariant, bordered, borderWidth, @@ -193,7 +200,7 @@ export const DefaultSelectControlComponent = memo( style={styles?.controlLabelNode} > = { positive: 'fgPositive', negative: 'fgNegative', foreground: 'fg', - foregroundMuted: 'fgMuted', + foregroundMuted: 'bgLine', secondary: 'bgSecondary', }; @@ -127,6 +127,17 @@ export const InputStack = memo(function InputStack(_props: InputStackProps) { } = mergedProps; const theme = useTheme(); const [inputAreaSize, onInputAreaLayout] = useLayout(); + const resolvedInputBackground = useMemo(() => { + if (variant === 'secondary') { + return 'bgSecondary'; + } + + if (variant === 'negative' && inputBackground === 'bg') { + return 'bgNegativeWash'; + } + + return inputBackground; + }, [variant, inputBackground]); const inputAreaStyle: ViewStyle = useMemo(() => { const inputBorderRadius: ViewStyle = { @@ -149,13 +160,12 @@ export const InputStack = memo(function InputStack(_props: InputStackProps) { variant === 'secondary' ? 'transparent' : theme.color[ - variant === 'foregroundMuted' || !variant ? 'bgLineHeavy' : variantColorMap[variant] + variant === 'foregroundMuted' || !variant ? 'bgLine' : variantColorMap[variant] ], borderWidth: theme.borderWidth[borderWidth], flexDirection: 'row', flexGrow: 1, - backgroundColor: - variant === 'secondary' ? theme.color.bgSecondary : theme.color[inputBackground], + backgroundColor: theme.color[resolvedInputBackground], borderRadius: theme.borderRadius[borderRadius], overflow: 'hidden', ...inputBorderRadius, @@ -168,7 +178,7 @@ export const InputStack = memo(function InputStack(_props: InputStackProps) { theme.borderWidth, theme.borderRadius, borderWidth, - inputBackground, + resolvedInputBackground, borderRadius, ]); diff --git a/packages/mobile/src/controls/NativeInput.tsx b/packages/mobile/src/controls/NativeInput.tsx index 16473d7a97..a525bb0f8e 100644 --- a/packages/mobile/src/controls/NativeInput.tsx +++ b/packages/mobile/src/controls/NativeInput.tsx @@ -11,6 +11,8 @@ import type { TextBaseProps } from '../typography/Text'; import type { TextInputBaseProps } from './TextInput'; +const defaultNativeInputBorderRadius: ThemeVars.BorderRadius = 400; + export type NativeInputProps = { /** * Text Align Input @@ -90,13 +92,15 @@ export const NativeInput = memo( ...containerSpacing, ...(!disabled && editableInputAddonProps.readOnly && { - backgroundColor: theme.color.bgSecondary, + backgroundColor: theme.color.bgSecondaryWash, + borderRadius: theme.borderRadius[defaultNativeInputBorderRadius], }), }; }, [ containerSpacing, theme.space, theme.color, + theme.borderRadius, compact, editableInputAddonProps.readOnly, disabled, diff --git a/packages/mobile/src/controls/Select.tsx b/packages/mobile/src/controls/Select.tsx index 5bba5dbcd8..7cce4db1ab 100644 --- a/packages/mobile/src/controls/Select.tsx +++ b/packages/mobile/src/controls/Select.tsx @@ -29,6 +29,7 @@ import { useSelect } from './useSelect'; const selectTriggerMinHeight = 56; const selectTriggerCompactMinHeight = 40; const selectTriggerInsideLabelMinHeight = 24; +const selectTriggerBorderRadius = 400; const variantColorMap: Record = { primary: 'fgPrimary', @@ -97,7 +98,14 @@ export const Select = memo( const [isSelectTrayOpen, toggleSelectTray] = useState(false); const toggleSelectTrayOff = useCallback(() => toggleSelectTray(false), [toggleSelectTray]); const toggleSelectTrayOn = useCallback(() => toggleSelectTray(true), [toggleSelectTray]); - const focusedVariant = useInputVariant(!!isSelectTrayOpen, variant); + const defaultFocusedVariant = useInputVariant(!!isSelectTrayOpen, variant); + const focusedVariant = useMemo(() => { + if (variant === 'foreground' || variant === 'foregroundMuted') { + return 'foreground'; + } + + return defaultFocusedVariant; + }, [defaultFocusedVariant, variant]); const sanitizedValue = defaultValue === '' ? undefined : defaultValue; const internalRef = useRef(null); const refs = useMergeRefs(ref, internalRef); @@ -180,6 +188,7 @@ export const Select = memo( = { secondary: 'bgSecondary', }; +const defaultTextInputBorderRadius: InputStackBaseProps['borderRadius'] = 400; + export const TextInput = memo( forwardRef((_props: TextInputProps, ref: ForwardedRef) => { const mergedProps = useComponentConfig('TextInput', _props); @@ -153,7 +154,7 @@ export const TextInput = memo( compact, suffix = '', accessibilityLabel, - borderRadius, + borderRadius = defaultTextInputBorderRadius, enableColorSurge = false, helperTextErrorIconAccessibilityLabel = 'error', bordered = true, @@ -165,11 +166,20 @@ export const TextInput = memo( } = mergedProps; const theme = useTheme(); const [focused, setFocused] = useState(false); - const focusedVariant = useInputVariant(focused, variant); + const isReadOnly = !!editableInputProps.readOnly; + const disableFocusedStyle = disabled || isReadOnly; + const shouldShowFocusedState = focused && !disableFocusedStyle; + const focusedVariant = useMemo(() => { + if (variant === 'foreground' || variant === 'foregroundMuted') { + return 'foreground'; + } + + return variant; + }, [variant]); const internalRef = useRef(null); const refs = useMergeRefs(ref, internalRef); const { borderFocusedStyle, borderUnfocusedStyle } = useInputBorderStyle( - focused, + shouldShowFocusedState, variant, focusedVariant, bordered, @@ -181,7 +191,7 @@ export const TextInput = memo( ...editableInputProps, onFocus: (e: NativeSyntheticEvent) => { editableInputProps?.onFocus?.(e); - setFocused(true); + setFocused(!disableFocusedStyle); }, onBlur: (e: NativeSyntheticEvent) => { editableInputProps?.onBlur?.(e); @@ -190,11 +200,11 @@ export const TextInput = memo( }; const handleNodePress = useCallback(() => { - if (!editableInputAddonProps.readOnly) { - setFocused(true); - internalRef.current?.focus(); - } - }, [setFocused, internalRef, editableInputAddonProps.readOnly]); + if (disableFocusedStyle) return; + + setFocused(true); + internalRef.current?.focus(); + }, [disableFocusedStyle]); const hasLabel = useMemo(() => !!label || !!labelNode, [label, labelNode]); @@ -236,11 +246,11 @@ export const TextInput = memo( }, [start]); const readOnlyInputBackground = useMemo(() => { - if (!disabled && editableInputAddonProps.readOnly) { - return 'bgSecondary'; + if (!disabled && isReadOnly) { + return 'bgSecondaryWash'; } return undefined; - }, [disabled, editableInputAddonProps.readOnly]); + }, [disabled, isReadOnly]); return ( ) } - focused={focused} + focused={shouldShowFocusedState} focusedBorderWidth={focusedBorderWidth} helperTextNode={ !!helperText && diff --git a/packages/mobile/src/controls/__stories__/TextInput.stories.tsx b/packages/mobile/src/controls/__stories__/TextInput.stories.tsx index 32fe0b8e0c..f80e656334 100644 --- a/packages/mobile/src/controls/__stories__/TextInput.stories.tsx +++ b/packages/mobile/src/controls/__stories__/TextInput.stories.tsx @@ -436,50 +436,32 @@ const InputScreen = () => { placeholder="Placeholder" /> - - - - - } - /> - - - } - label="Username" - labelVariant="inside" - placeholder="john.doe@coinbase.com" - /> - - - } - label="Username" - labelVariant="inside" - placeholder="john.doe@coinbase.com" - start={} - /> - - - - - - + + + + + Figma inside-label states + + + + Default + + + + Filled + + + + Read only + + + + Negative + + + + + { ); }); + it('uses inverse focused border color for foreground-muted variant', () => { + const testID = 'input-testid'; + render( + + + , + ); + + fireEvent(screen.getByTestId(testID), 'focus'); + const focusedBorderOverlayStyle = getFocusedBorderOverlayStyle(); + expect(focusedBorderOverlayStyle).toEqual( + expect.objectContaining({ borderColor: defaultTheme.lightColor.bgInverse }), + ); + }); + + it('does not show focused border styling when readOnly', () => { + const testID = 'input-readonly-testid'; + render( + + + , + ); + + fireEvent(screen.getByTestId(testID), 'focus'); + const focusedBorderOverlayStyle = getFocusedBorderOverlayStyle(); + expect(focusedBorderOverlayStyle).toBeUndefined(); + }); + it('renders label outside by default', () => { const labelTestID = 'label-test'; render( diff --git a/packages/mobile/src/dates/__stories__/DateInput.stories.tsx b/packages/mobile/src/dates/__stories__/DateInput.stories.tsx index c9858542fa..bbf4b1bf04 100644 --- a/packages/mobile/src/dates/__stories__/DateInput.stories.tsx +++ b/packages/mobile/src/dates/__stories__/DateInput.stories.tsx @@ -23,27 +23,40 @@ const sharedProps = { requiredError: 'This field is required', }; -export const Examples = () => { - const [date, setDate] = useState(today); +const DateInputExample = ({ + initialDate = today, + ...props +}: Omit< + React.ComponentProps, + 'date' | 'onChangeDate' | 'error' | 'onErrorDate' +> & { + initialDate?: Date | null; +}) => { + const [date, setDate] = useState(initialDate); const [error, setError] = useState(null); - const props = { date, onChangeDate: setDate, error, onErrorDate: setError }; + + return ( + + ); +}; + +export const Examples = () => { return ( - + - + - + - - - - + + + @@ -54,17 +67,16 @@ export const Examples = () => { } /> - } placeholder="Hello world" start={} /> - - - - + + + + diff --git a/packages/mobile/src/hooks/useInputBorderAnimation.tsx b/packages/mobile/src/hooks/useInputBorderAnimation.tsx index 4793a3187c..e5370e4ffd 100644 --- a/packages/mobile/src/hooks/useInputBorderAnimation.tsx +++ b/packages/mobile/src/hooks/useInputBorderAnimation.tsx @@ -29,7 +29,7 @@ const variantColorMap: Record = { positive: 'bgPositive', negative: 'bgNegative', foreground: 'bgInverse', - foregroundMuted: 'bgLineHeavy', + foregroundMuted: 'bgLine', secondary: 'bgSecondary', }; diff --git a/packages/web/src/alpha/select/DefaultSelectControl.tsx b/packages/web/src/alpha/select/DefaultSelectControl.tsx index 2c93074c3f..3f7a09a7ab 100644 --- a/packages/web/src/alpha/select/DefaultSelectControl.tsx +++ b/packages/web/src/alpha/select/DefaultSelectControl.tsx @@ -26,6 +26,7 @@ import { const LABEL_VARIANT_INSIDE_HEIGHT = 32; const COMPACT_HEIGHT = 40; const DEFAULT_HEIGHT = 56; +const selectControlBorderRadius = 400; const noFocusOutlineCss = css` &:focus, @@ -246,7 +247,7 @@ const DefaultSelectControlComponent = memo( setOpen((s) => !s)} tabIndex={-1}> {label} @@ -491,6 +492,7 @@ const DefaultSelectControlComponent = memo( { consoleError.mockRestore(); }); + + it('throws error when multi select receives a non-array value', () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + render( + + + , + ); + }).toThrow('Select component value must not be an array when "type" is "single".'); + + consoleError.mockRestore(); + }); }); describe('Keyboard Navigation', () => { diff --git a/packages/web/src/controls/InputLabel.tsx b/packages/web/src/controls/InputLabel.tsx index 7168159bb6..3e7db5b627 100644 --- a/packages/web/src/controls/InputLabel.tsx +++ b/packages/web/src/controls/InputLabel.tsx @@ -5,7 +5,7 @@ import { Text, type TextProps } from '../typography/Text'; export type InputLabelProps = TextProps<'label'>; export const InputLabel = memo(function InputLabel({ - color = 'fg', + color = 'fgMuted', disabled = false, font = 'label1', ...props diff --git a/packages/web/src/controls/InputStack.tsx b/packages/web/src/controls/InputStack.tsx index 3081d0c944..2dfc81f3f7 100644 --- a/packages/web/src/controls/InputStack.tsx +++ b/packages/web/src/controls/InputStack.tsx @@ -66,7 +66,7 @@ const variantColorMap: Record = { positive: 'bgPositive', negative: 'bgNegative', foreground: 'bgInverse', - foregroundMuted: 'bgLineHeavy', + foregroundMuted: 'bgLine', secondary: 'bgSecondary', }; @@ -204,12 +204,19 @@ export const InputStack = memo( return 'transparent'; } + if (variant === 'secondary') { + return 'transparent'; + } + + if (variant === 'foreground' || variant === 'foregroundMuted') { + return 'var(--color-bgInverse)'; + } + if (variant === 'positive' || variant === 'negative') { return `var(--color-${variantColorMap[variant]})`; } - // all variants except for positive/negative receive the primary focus color - return 'var(--color-bgPrimary)'; + return `var(--color-${variantColorMap[variant]})`; }, [disableFocusedStyle, variant]); const borderColorUnfocused = useMemo(() => { @@ -229,6 +236,18 @@ export const InputStack = memo( }; }, [borderColorUnfocused, borderColorFocused, focusedBorderWidth, inputBorderRadius]); + const resolvedInputBackground = useMemo(() => { + if (variant === 'secondary') { + return 'bgSecondary'; + } + + if (variant === 'negative' && inputBackground === 'bg') { + return 'bgNegativeWash'; + } + + return inputBackground; + }, [variant, inputBackground]); + return ( (function SelectStack( { @@ -86,6 +88,7 @@ export const SelectStack = memo( } labelVariant={labelVariant} variant={variant} + borderRadius={defaultSelectBorderRadius} width="100%" /> ); diff --git a/packages/web/src/controls/SelectTrigger.tsx b/packages/web/src/controls/SelectTrigger.tsx index 219aefd3ad..03625d57a0 100644 --- a/packages/web/src/controls/SelectTrigger.tsx +++ b/packages/web/src/controls/SelectTrigger.tsx @@ -18,6 +18,7 @@ import { SelectStack } from './SelectStack'; const selectTriggerMinHeight = 56; const selectTriggerCompactMinHeight = 40; const selectTriggerInsideLabelMinHeight = 62; +const selectTriggerBorderRadius = 400; export type SelectTriggerProps = Omit< SelectBaseProps, @@ -129,7 +130,7 @@ export const SelectTrigger = memo( ) : null} { - return useMemo( - () => (focused && variant !== 'positive' && variant !== 'negative' ? 'primary' : variant), - [focused, variant], - ); -}; - const variantColorMap: Record = { primary: 'fgPrimary', positive: 'fgPositive', @@ -148,6 +141,8 @@ const variantColorMap: Record = { secondary: 'fg', }; +const defaultTextInputBorderRadius: InputStackBaseProps['borderRadius'] = 400; + export const TextInput = memo( forwardRef(function TextInput(_props: TextInputProps, ref: React.ForwardedRef) { const mergedProps = useComponentConfig('TextInput', _props); @@ -170,7 +165,7 @@ export const TextInput = memo( suffix = '', onFocus, onBlur, - borderRadius = 200, + borderRadius = defaultTextInputBorderRadius, height, inputNode, bordered = true, @@ -182,8 +177,11 @@ export const TextInput = memo( inputBackground, ...nativeInputRestProps } = mergedProps; + const isReadOnly = !!nativeInputRestProps.readOnly; + const disableFocusedStyle = disabled || isReadOnly; const [focused, setFocused] = useState(false); - const focusedVariant = useInputVariant(focused, variant); + const shouldShowFocusedState = focused && !disableFocusedStyle; + const focusedVariant = variant; const internalRef = useRef(); const refs = useMergeRefs(ref, internalRef); @@ -204,11 +202,13 @@ export const TextInput = memo( const handleOnFocus = useCallback( (e: React.FocusEvent) => { - setFocused(true); + setFocused(!disableFocusedStyle); onFocus?.(e); - internalRef.current?.addEventListener('wheel', preventWheelScroll); + if (!disableFocusedStyle) { + internalRef.current?.addEventListener('wheel', preventWheelScroll); + } }, - [onFocus, internalRef, preventWheelScroll], + [disableFocusedStyle, onFocus, internalRef, preventWheelScroll], ); const handleOnBlur = useCallback( @@ -221,14 +221,16 @@ export const TextInput = memo( ); const handleNodePress = useCallback(() => { + if (disableFocusedStyle) return; + setFocused(true); internalRef.current?.focus(); - }, [setFocused, internalRef]); + }, [disableFocusedStyle]); // Define a distinct read-only style to differentiate it from the disabled style. const readOnlyInputBackground = useMemo(() => { if (!disabled && nativeInputRestProps.readOnly) { - return 'bgSecondary'; + return 'bgSecondaryWash'; } return undefined; }, [disabled, nativeInputRestProps.readOnly]); @@ -297,11 +299,15 @@ export const TextInput = memo( ]); return ( - + ) } - focused={focused} + focused={shouldShowFocusedState} focusedBorderWidth={focusedBorderWidth} height={height} helperTextNode={ diff --git a/packages/web/src/controls/__stories__/TextInput.stories.tsx b/packages/web/src/controls/__stories__/TextInput.stories.tsx index df28ee49c4..de420bcc68 100644 --- a/packages/web/src/controls/__stories__/TextInput.stories.tsx +++ b/packages/web/src/controls/__stories__/TextInput.stories.tsx @@ -62,38 +62,78 @@ export const Basic = function Basic() { }; export const InsideLabel = function InsideLabel() { + const figmaStates = [ + { + title: 'Default', + node: , + }, + { + title: 'Filled', + node: ( + undefined} value="Text" /> + ), + }, + { + title: 'Read only', + node: , + }, + { + title: 'Negative', + node: , + }, + ] as const; + return ( - - - } - variant="secondary" - /> - } - label=" Secondary End" - labelVariant="inside" - placeholder="Placeholder" - variant="secondary" - /> - - - + + + + Figma inside-label states + + + {figmaStates.map((state) => ( + + + {state.title} + + {state.node} + + ))} + + + + Existing inside-label variations + + + } + variant="secondary" + /> + } + label=" Secondary End" + labelVariant="inside" + placeholder="Placeholder" + variant="secondary" + /> + + + + ); }; diff --git a/packages/web/src/controls/__tests__/TextInput.test.tsx b/packages/web/src/controls/__tests__/TextInput.test.tsx index e4a12169c1..752d4d5699 100644 --- a/packages/web/src/controls/__tests__/TextInput.test.tsx +++ b/packages/web/src/controls/__tests__/TextInput.test.tsx @@ -280,10 +280,32 @@ describe('TextInput', () => { ); const inputArea = screen.getByTestId('input-interactable-area'); - expect(inputArea).toHaveStyle('--border-color-focused: var(--color-bgPrimary)'); + expect(inputArea).toHaveStyle('--border-color-focused: var(--color-bgInverse)'); expect(inputArea).toHaveStyle('--border-width-focused: var(--borderWidth-200)'); }); + it('disables focus border styling when readOnly', () => { + render( + + + , + ); + + const inputArea = screen.getByTestId('input-interactable-area'); + expect(inputArea).toHaveStyle('--border-color-focused: transparent'); + }); + + it('disables focus border styling when disabled', () => { + render( + + + , + ); + + const inputArea = screen.getByTestId('input-interactable-area'); + expect(inputArea).toHaveStyle('--border-color-focused: transparent'); + }); + it('focuses input when start node is pressed', () => { const onFocus = jest.fn(); const startNodeText = 'Start';