diff --git a/.cursor/commands/open-pr.md b/.cursor/commands/open-pr.md index 78b4eb9b9..b389f0f23 100644 --- a/.cursor/commands/open-pr.md +++ b/.cursor/commands/open-pr.md @@ -29,22 +29,19 @@ git diff main...HEAD --name-only -- .nx/version-plans/ - Files under `libs/design-core/` → `'@ledgerhq/lumen-design-core'` - Files under `libs/utils-shared/` → `'@ledgerhq/lumen-utils-shared'` -Pick the bump type based on the change: -- `patch` — bug fixes, small tweaks -- `minor` — new features, new components, new props -- `major` — breaking changes +Always use `patch` as the bump type — never `minor`, never `major` — regardless of the change (feature, fix, breaking change, etc.). See `.cursor/rules/shared/release-plan.mdc`. -Write the file as `.nx/version-plans/version-plan-.md` with this format: +Create **one file per affected package** — never group multiple packages in the same file. If N packages are affected, create N files named `.nx/version-plans/version-plan--.md`, each with a single-package frontmatter: ```markdown --- -'@ledgerhq/lumen-ui-rnative': minor +'@ledgerhq/lumen-ui-rnative': patch --- feat(Select): add render prop and SelectButtonTrigger ``` -The description line should match the PR title / commit message style. If multiple packages are affected, list them all in the frontmatter. +The description line should match the PR title / commit message style. ## Step 2: Create a commit if needed diff --git a/.cursor/rules/shared/release-plan.mdc b/.cursor/rules/shared/release-plan.mdc new file mode 100644 index 000000000..504cfa7f6 --- /dev/null +++ b/.cursor/rules/shared/release-plan.mdc @@ -0,0 +1,63 @@ +--- +description: Nx version plans must always use `patch` bump type, regardless of change +globs: +alwaysApply: true +--- + +# Release plan bump type + +When generating an Nx version plan in `.nx/version-plans/`, the bump type +in the frontmatter MUST always be `patch` — never `minor`, never `major` — +regardless of the nature of the change (new feature, new component, new prop, +breaking change, refactor, fix, etc.). + +```markdown +--- +'@ledgerhq/lumen-ui-rnative': patch +--- + +feat(Select): add render prop and SelectButtonTrigger +``` + +- Do not infer `minor` from `feat(...)` commits. +- Do not infer `major` from breaking changes. + +## One package per file + +Each release plan file MUST list a single package in its frontmatter — never +group multiple packages in the same file. If a change affects N packages, +create N separate `version-plan--.md` files, one per package. + +```markdown +--- +'@ledgerhq/lumen-ui-react': patch +--- + +feat(Select): add render prop and SelectButtonTrigger +``` + +```markdown +--- +'@ledgerhq/lumen-ui-rnative': patch +--- + +feat(Select): add render prop and SelectButtonTrigger +``` + +Do NOT do this: + +```markdown +--- +'@ledgerhq/lumen-ui-react': patch +'@ledgerhq/lumen-ui-rnative': patch +--- +``` + +This applies to all packages, including but not limited to: + +- `@ledgerhq/lumen-ui-react` +- `@ledgerhq/lumen-ui-rnative` +- `@ledgerhq/lumen-ui-react-visualization` +- `@ledgerhq/lumen-ui-rnative-visualization` +- `@ledgerhq/lumen-design-core` +- `@ledgerhq/lumen-utils-shared` diff --git a/.nx/version-plans/version-plan-1776837601000.md b/.nx/version-plans/version-plan-1776837601000.md new file mode 100644 index 000000000..2afed8c75 --- /dev/null +++ b/.nx/version-plans/version-plan-1776837601000.md @@ -0,0 +1,58 @@ +--- +'@ledgerhq/lumen-ui-react': patch +--- + +feat(Input): replace `errorMessage` with `helperText` and `status` across react inputs + +## Breaking change + +The input feedback API changed across React input components. + +- Removed: `errorMessage` +- Added: `helperText` +- Added: `status` with `error | success` + +This affects `TextInput`, `SearchInput`, `AddressInput`, `BaseInput`, and `SelectSearch`. + +## Migration guide + +### Error feedback + +```diff +- ++ +``` + +### Error feedback — `aria-invalid` is now auto-set + +`aria-invalid={true}` is automatically set on the input when `status="error"`. You no longer need to pass it explicitly. + +```diff +- ++ +``` + +## New addition: + +### Neutral helper copy + +```diff +- ++ +``` + +### Success feedback + +```diff +- ++ +``` diff --git a/.nx/version-plans/version-plan-1776837602000.md b/.nx/version-plans/version-plan-1776837602000.md new file mode 100644 index 000000000..31b5b7218 --- /dev/null +++ b/.nx/version-plans/version-plan-1776837602000.md @@ -0,0 +1,53 @@ +--- +'@ledgerhq/lumen-ui-rnative': patch +--- + +feat(Input): replace `errorMessage` with `helperText` and `status` across native inputs + +## Breaking change + +The input feedback API changed across React Native input components. + +- Removed: `errorMessage` +- Added: `helperText` +- Added: `status` with `error | success` + +This affects `TextInput`, `SearchInput`, `AddressInput`, and `BaseInput`. + +## Migration guide + +### Error feedback + +```diff +- ++ +``` + +### Search validation feedback + +```diff +- ++ +``` + +## New addition: + +### Neutral helper copy + +```diff +- ++ +``` + +### Success feedback + +```diff +- ++ +``` diff --git a/apps/app-sandbox-rnative/src/app/blocks/TextInputs.tsx b/apps/app-sandbox-rnative/src/app/blocks/TextInputs.tsx index d8f4a22bc..359829095 100644 --- a/apps/app-sandbox-rnative/src/app/blocks/TextInputs.tsx +++ b/apps/app-sandbox-rnative/src/app/blocks/TextInputs.tsx @@ -31,6 +31,7 @@ export function TextInputs() { ]) } /> + + + openQrScanner} - errorMessage={props.errorMessage.label} + helperText={props.helperText.label} + status={props.status} /> ), }, diff --git a/libs/ui-react/src/lib/Components/AddressInput/AddressInput.mdx b/libs/ui-react/src/lib/Components/AddressInput/AddressInput.mdx index e2080b4c4..2a961f981 100644 --- a/libs/ui-react/src/lib/Components/AddressInput/AddressInput.mdx +++ b/libs/ui-react/src/lib/Components/AddressInput/AddressInput.mdx @@ -27,7 +27,7 @@ AddressInput is designed specifically where users need to enter wallet addresses - **Customizable prefix label** - defaults to "To:" but can be overridden via prefix prop - **Conditional QR code scanner** - Appears only when onQrCodeClick handler is provided, using consistent InteractiveIcon styling - **Smart clear button** - Appears automatically when content is entered -- **Error handling** - Supports error states and error messages +- **Error handling** - Supports validation feedback via `helperText` and `status` @@ -178,8 +178,8 @@ React.useEffect(() => { setAddress(e.target.value)} - errorMessage={error} - aria-invalid={!!error} + helperText={error || undefined} + status={error ? 'error' : undefined} />; ``` @@ -256,8 +256,8 @@ const handleQrScan = () => { placeholder='Enter address or ENS' value={recipientAddress} onChange={(e) => setRecipientAddress(e.target.value)} - errorMessage={addressError} - aria-invalid={!!addressError} + helperText={addressError || undefined} + status={addressError ? 'error' : undefined} className='max-w-md' /> {/* Other transaction fields */} @@ -448,8 +448,8 @@ The component includes comprehensive test coverage for: {/* prettier-ignore */} @@ -458,15 +458,15 @@ The component includes comprehensive test coverage for: placeholder='Enter address or ENS' value={address} onChange={handleChange} - aria-invalid={hasError} - errorMessage={hasError ? 'Invalid address' : undefined} + helperText={hasError ? 'Invalid address' : undefined} + status={hasError ? 'error' : undefined} /> ``` {/* prettier-ignore */} @@ -475,7 +475,7 @@ The component includes comprehensive test coverage for: placeholder='Enter address or ENS' value={address} onChange={handleChange} - errorMessage={hasError ? 'Invalid address' : undefined} + aria-invalid={hasError} /> ``` diff --git a/libs/ui-react/src/lib/Components/AddressInput/AddressInput.stories.tsx b/libs/ui-react/src/lib/Components/AddressInput/AddressInput.stories.tsx index 5e4bff837..d3c96cb2f 100644 --- a/libs/ui-react/src/lib/Components/AddressInput/AddressInput.stories.tsx +++ b/libs/ui-react/src/lib/Components/AddressInput/AddressInput.stories.tsx @@ -114,8 +114,8 @@ export const Error: Story = { args: { placeholder: 'Enter address or ENS', defaultValue: 'invalid-address-format', - errorMessage: 'Invalid address format', - 'aria-invalid': true, + helperText: 'Invalid address format', + status: 'error', className: 'max-w-md', onQrCodeClick: () => console.log('QR code clicked!'), }, @@ -125,8 +125,8 @@ export const Error: Story = { code: ``, }, @@ -178,8 +178,8 @@ export const Controlled: Story = { setError(''); // Clear error state console.log('Address cleared'); }} - errorMessage={error} - aria-invalid={!!error} + helperText={error || undefined} + status={error ? 'error' : undefined} className='max-w-md' /> diff --git a/libs/ui-react/src/lib/Components/AddressInput/AddressInput.test.tsx b/libs/ui-react/src/lib/Components/AddressInput/AddressInput.test.tsx index 04bf8de58..c2d7a9618 100644 --- a/libs/ui-react/src/lib/Components/AddressInput/AddressInput.test.tsx +++ b/libs/ui-react/src/lib/Components/AddressInput/AddressInput.test.tsx @@ -184,17 +184,17 @@ describe('AddressInput', () => { expect(handleClear).toHaveBeenCalled(); }); - it('displays error message when provided', () => { + it('displays helper text when provided with error status', () => { render( , ); - const errorMessage = screen.getByText('Invalid address format'); - expect(errorMessage).toBeInTheDocument(); + const helperTextEl = screen.getByText('Invalid address format'); + expect(helperTextEl).toBeInTheDocument(); // The role="alert" is on the error container, not the text span const errorContainer = screen.getByRole('alert'); diff --git a/libs/ui-react/src/lib/Components/AddressInput/AddressInput.tsx b/libs/ui-react/src/lib/Components/AddressInput/AddressInput.tsx index b46df2ce9..f0996e707 100644 --- a/libs/ui-react/src/lib/Components/AddressInput/AddressInput.tsx +++ b/libs/ui-react/src/lib/Components/AddressInput/AddressInput.tsx @@ -13,7 +13,7 @@ import type { AddressInputProps } from './types'; * - **Automatic clear button** appears when input has content * - **Conditional QR code scanner** appears only when onQrCodeClick handler is provided * - **ENS and address support** optimized for cryptocurrency address entry - * - **Error state styling** with aria-invalid and errorMessage support + * - **Helper text** with optional `status` for validation feedback * - **Flexible styling** via className prop * * ## Clear Button Behavior @@ -27,12 +27,12 @@ import type { AddressInputProps } from './types'; * // Basic address field with automatic clear button * setAddress(e.target.value)} /> * - * // Address field with error state + * // Address field with error state (aria-invalid is auto-set when status="error") * setInvalidAddress(e.target.value)} - * aria-invalid={!isValid} - * errorMessage="Please enter a valid address or ENS name" + * helperText="Please enter a valid address or ENS name" + * status="error" * /> * * // Address field with QR scanner diff --git a/libs/ui-react/src/lib/Components/AmountInput/AmountInput.stories.tsx b/libs/ui-react/src/lib/Components/AmountInput/AmountInput.stories.tsx index 82bd4311d..bd7a3e8bc 100644 --- a/libs/ui-react/src/lib/Components/AmountInput/AmountInput.stories.tsx +++ b/libs/ui-react/src/lib/Components/AmountInput/AmountInput.stories.tsx @@ -110,7 +110,7 @@ export const LargeAmountDisplay: Story = { // Check if value exceeds max length (6 digits) const digitCount = value.replace(/\D/g, '').length; const hasError = digitCount > 3; - const errorMessage = hasError ? 'Insufficient balance' : ''; + const balanceErrorText = hasError ? 'Insufficient balance' : ''; return (
@@ -131,7 +131,9 @@ export const LargeAmountDisplay: Story = {
{hasError && ( -
{errorMessage}
+
+ {balanceErrorText} +
)} > = {}, +): ComponentProps => ({ + onChange: vi.fn(), + ...overrides, +}); + +describe('BaseInput', () => { + it('shows neutral helper text without invalid or alert semantics', () => { + render( + , + ); + + const input = screen.getByRole('textbox'); + + expect(input).toHaveAttribute('aria-describedby', 'wallet-address-helper'); + expect(input).not.toHaveAttribute('aria-invalid'); + expect(screen.getByText('Enter your ETH address')).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(document.querySelector('svg')).not.toBeInTheDocument(); + }); + + it('shows success helper text with icon and keeps the input valid', () => { + render( + , + ); + + const input = screen.getByRole('textbox'); + + expect(input).toHaveAttribute('aria-describedby', 'recipient-helper'); + expect(input).not.toHaveAttribute('aria-invalid'); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.getByText('Address verified')).toBeInTheDocument(); + expect(document.querySelector('svg.text-success')).toBeInTheDocument(); + }); + + it('derives invalid semantics from error status and styles the label', () => { + render( + , + ); + + const input = screen.getByRole('textbox'); + const label = screen.getByText('Email'); + + expect(input).toHaveAttribute('aria-describedby', 'email-helper'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(label).toHaveClass('text-error'); + expect(document.querySelector('svg.text-error')).toBeInTheDocument(); + }); + + it('lets explicit aria-invalid override the derived error state', () => { + render( + , + ); + + expect(screen.getByRole('textbox')).toHaveAttribute( + 'aria-invalid', + 'false', + ); + }); + + it('defaults to a single-space placeholder when there is no label and no placeholder prop (placeholder-shown + legacy behavior)', () => { + render( + + To: + + } + {...createProps()} + />, + ); + expect(screen.getByRole('textbox')).toHaveAttribute('placeholder', ' '); + }); + + it('supports label and placeholder together without using the placeholder-only label position', () => { + render( + , + ); + + const input = screen.getByRole('textbox'); + expect(input).toHaveAttribute('placeholder', 'jane.doe'); + + const label = screen.getByText('Username'); + expect(label.className).toContain('peer-placeholder-shown:top-6'); + }); + + it('does not add helper semantics when helperText is omitted', () => { + render( + , + ); + + const input = screen.getByRole('textbox'); + + expect(input).not.toHaveAttribute('aria-describedby'); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(document.querySelector('svg.text-success')).not.toBeInTheDocument(); + }); +}); diff --git a/libs/ui-react/src/lib/Components/BaseInput/BaseInput.tsx b/libs/ui-react/src/lib/Components/BaseInput/BaseInput.tsx index 0d270bb7a..10feb1b58 100644 --- a/libs/ui-react/src/lib/Components/BaseInput/BaseInput.tsx +++ b/libs/ui-react/src/lib/Components/BaseInput/BaseInput.tsx @@ -1,21 +1,36 @@ import { cn, + resolveBaseInputPlaceholder, useDisabledContext, useMergedRef, } from '@ledgerhq/lumen-utils-shared'; +import { cva } from 'class-variance-authority'; import type { ChangeEvent, PointerEvent } from 'react'; import { useRef, useId, useState, useCallback } from 'react'; import { useCommonTranslation } from '../../../i18n'; -import { DeleteCircleFill } from '../../Symbols'; +import { CheckmarkCircleFill, DeleteCircleFill } from '../../Symbols'; import { InteractiveIcon } from '../InteractiveIcon'; import type { BaseInputProps } from './types'; -const baseContainerStyles = cn( - 'group relative flex h-48 w-full cursor-text items-center gap-8 rounded-sm bg-muted px-16 transition-colors', - 'focus-within:ring-2 focus-within:ring-active hover:bg-muted-hover', - 'has-disabled:cursor-not-allowed has-disabled:bg-disabled has-disabled:text-disabled', - 'has-invalid:border-error has-invalid:ring-1 has-invalid:ring-error', - 'has-[input[aria-invalid="true"]]:border-error has-[input[aria-invalid="true"]]:ring-1 has-[input[aria-invalid="true"]]:ring-error', +const containerVariants = cva( + [ + 'group relative flex h-48 w-full cursor-text items-center gap-8 rounded-sm bg-muted px-16 transition-colors', + 'focus-within:ring-2 focus-within:ring-active hover:bg-muted-hover', + 'has-disabled:cursor-not-allowed has-disabled:bg-disabled has-disabled:text-disabled', + ], + { + variants: { + status: { + default: '', + error: 'ring-1 ring-error focus-within:ring-2 focus-within:ring-error', + success: + 'ring-1 ring-success focus-within:ring-2 focus-within:ring-success', + }, + }, + defaultVariants: { + status: 'default', + }, + }, ); const baseInputStyles = cn( @@ -35,6 +50,37 @@ const baseLabelStyles = cn( 'w-[calc(100%-var(--size-56))] truncate', ); +const labelVariants = cva(baseLabelStyles, { + variants: { + status: { + default: '', + error: 'text-error', + success: '', + }, + floated: { + true: 'peer-placeholder-shown:top-6 peer-placeholder-shown:translate-y-0 peer-placeholder-shown:body-4', + false: '', + }, + }, + defaultVariants: { + status: 'default', + floated: false, + }, +}); + +const helperVariants = cva('mt-8 flex items-center gap-2 body-3', { + variants: { + status: { + default: 'text-muted', + error: 'text-error', + success: 'text-success', + }, + }, + defaultVariants: { + status: 'default', + }, +}); + /** * Base input component with floating label, error state styling, and clear button functionality. * Shows a clear button by default when input has content. Use hideClearButton to hide it. @@ -64,13 +110,15 @@ export const BaseInput = ({ label, id, disabled: disabledProp, - errorMessage, + helperText, + status, suffix, prefix, onClear, hideClearButton = false, 'aria-invalid': ariaInvalidProp, onChange: onChangeProp, + placeholder: placeholderProp, ...props }: BaseInputProps) => { const disabled = useDisabledContext({ @@ -83,12 +131,8 @@ export const BaseInput = ({ const reactId = useId(); const inputId = id || `input-${reactId}`; - // Handle aria-invalid properly - use provided value or derive from errorMessage - const ariaInvalid = ariaInvalidProp - ? ariaInvalidProp - : errorMessage - ? true - : undefined; + const ariaInvalid = + ariaInvalidProp ?? (status === 'error' ? true : undefined); const isControlled = props.value !== undefined; @@ -118,9 +162,16 @@ export const BaseInput = ({ ? !!props.value && props.value.toString().length > 0 : uncontrolledValue.length > 0; + const { inputPlaceholder, labelStaysFloatedWithPlaceholder } = + resolveBaseInputPlaceholder({ + label, + placeholder: placeholderProp, + }); + const showClearButton = hasContent && !disabled && !hideClearButton; - const errorId = `${inputId}-error`; + const helperId = `${inputId}-helper`; + const showHelper = !!helperText; const handleClear = () => { if (!inputRef.current) return; @@ -150,7 +201,7 @@ export const BaseInput = ({ return (
) => { const target = event.target as Element; if (target.closest('input, button, a')) return; @@ -180,9 +231,9 @@ export const BaseInput = ({ ref={composedRef} id={inputId} disabled={disabled} - placeholder=' ' + placeholder={inputPlaceholder} aria-invalid={ariaInvalid} - aria-describedby={errorMessage ? errorId : undefined} + aria-describedby={showHelper ? helperId : undefined} className={cn( baseInputStyles, label && 'pt-12 body-2', @@ -196,8 +247,10 @@ export const BaseInput = ({
- {errorMessage && ( + {showHelper && ( )}
diff --git a/libs/ui-react/src/lib/Components/BaseInput/types.ts b/libs/ui-react/src/lib/Components/BaseInput/types.ts index c127c6194..8c699e2a1 100644 --- a/libs/ui-react/src/lib/Components/BaseInput/types.ts +++ b/libs/ui-react/src/lib/Components/BaseInput/types.ts @@ -1,17 +1,26 @@ import type { ComponentPropsWithRef, ReactNode } from 'react'; +export type BaseInputStatus = 'error' | 'success'; + export type BaseInputProps = { /** * The label text that floats above the input when focused or filled. */ label?: string; /** - * An optional error message displayed below the input + * Optional text shown below the input (hint, error, or success copy). + * Pair with `status` for error/success styling and icons; omit `status` for a neutral hint. */ - errorMessage?: string; + helperText?: string; /** - * Indicates whether the input value is invalid - * @default false + * Visual state for border, label, helper text, and helper icon. + * Omit when `helperText` is a neutral hint. + */ + status?: BaseInputStatus; + /** + * Overrides the accessibility invalid state on the input element. + * Automatically set to `true` when `status` is `'error'` — only pass this + * explicitly when you need to decouple the accessibility state from the visual state. */ 'aria-invalid'?: boolean; /** diff --git a/libs/ui-react/src/lib/Components/SearchInput/SearchInput.mdx b/libs/ui-react/src/lib/Components/SearchInput/SearchInput.mdx index 4aafb0811..a9652720c 100644 --- a/libs/ui-react/src/lib/Components/SearchInput/SearchInput.mdx +++ b/libs/ui-react/src/lib/Components/SearchInput/SearchInput.mdx @@ -54,7 +54,7 @@ Appears automatically when input has content. Use `hideClearButton` to hide it o ### Error State -Supports `aria-invalid` and `errorMessage` props for error handling with proper ARIA labeling. +Supports `helperText` and `status` for error handling. Setting `status='error'` drives the visual error styling and automatically sets `aria-invalid={true}` on the input for screen readers. @@ -194,8 +194,8 @@ function MyComponent() { placeholder='Search users' value={query} onChange={(e) => setQuery(e.target.value)} - aria-invalid={hasError} - errorMessage={hasError ? 'Search failed' : undefined} + helperText={hasError ? 'Search failed' : undefined} + status={hasError ? 'error' : undefined} /> ``` @@ -305,8 +305,8 @@ function MyComponent() { {/* prettier-ignore */} @@ -315,15 +315,15 @@ function MyComponent() { placeholder='Search users' value={query} onChange={handleChange} - aria-invalid={hasError} - errorMessage={hasError ? 'Search failed' : undefined} + helperText={hasError ? 'Search failed' : undefined} + status={hasError ? 'error' : undefined} /> ``` {/* prettier-ignore */} @@ -332,7 +332,7 @@ function MyComponent() { placeholder='Search users' value={query} onChange={handleChange} - errorMessage={hasError ? 'Search failed' : undefined} + aria-invalid={hasError} /> ``` diff --git a/libs/ui-react/src/lib/Components/SearchInput/SearchInput.stories.tsx b/libs/ui-react/src/lib/Components/SearchInput/SearchInput.stories.tsx index 7825fc0a5..9e51e46fa 100644 --- a/libs/ui-react/src/lib/Components/SearchInput/SearchInput.stories.tsx +++ b/libs/ui-react/src/lib/Components/SearchInput/SearchInput.stories.tsx @@ -106,8 +106,8 @@ export const Error: Story = { args: { placeholder: 'Search text', defaultValue: 'Invalid search', - errorMessage: 'Search term is invalid', - 'aria-invalid': true, + helperText: 'Search term is invalid', + status: 'error', className: 'max-w-md', }, parameters: { @@ -116,8 +116,8 @@ export const Error: Story = { code: ``, }, diff --git a/libs/ui-react/src/lib/Components/SearchInput/SearchInput.test.tsx b/libs/ui-react/src/lib/Components/SearchInput/SearchInput.test.tsx index d6518508b..aec140cc5 100644 --- a/libs/ui-react/src/lib/Components/SearchInput/SearchInput.test.tsx +++ b/libs/ui-react/src/lib/Components/SearchInput/SearchInput.test.tsx @@ -82,17 +82,17 @@ describe('SearchInput', () => { expect(handleClear).toHaveBeenCalled(); }); - it('displays error message when provided', () => { + it('displays helper text when provided with error status', () => { render( , ); - const errorMessage = screen.getByText('Search failed'); - expect(errorMessage).toBeInTheDocument(); + const helperTextEl = screen.getByText('Search failed'); + expect(helperTextEl).toBeInTheDocument(); // The role="alert" is on the error container, not the text span const errorContainer = screen.getByRole('alert'); diff --git a/libs/ui-react/src/lib/Components/SearchInput/SearchInput.tsx b/libs/ui-react/src/lib/Components/SearchInput/SearchInput.tsx index 7b8365618..1d4724412 100644 --- a/libs/ui-react/src/lib/Components/SearchInput/SearchInput.tsx +++ b/libs/ui-react/src/lib/Components/SearchInput/SearchInput.tsx @@ -38,7 +38,7 @@ const inputVariants = cva('', { * - **Automatic clear button** appears when input has content * - **No label support** - uses placeholder text for optimal search UX * - **Suffix elements** for icons, buttons, or custom content - * - **Error state styling** with aria-invalid and errorMessage support + * - **Helper text** with optional `status` for validation feedback * - **Flexible styling** via className prop * * ## Clear Button Behavior @@ -56,13 +56,13 @@ const inputVariants = cva('', { * // Basic search with automatic clear button * setQuery(e.target.value)} /> * - * // Search with error state + * // Search with error state (aria-invalid is auto-set when status="error") * setSearchTerm(e.target.value)} - * aria-invalid={!isValid} - * errorMessage="Search failed. Please try again." + * helperText="Search failed. Please try again." + * status="error" * /> * * // Search with suffix element diff --git a/libs/ui-react/src/lib/Components/Select/Select.tsx b/libs/ui-react/src/lib/Components/Select/Select.tsx index 3ee53d443..fa650e994 100644 --- a/libs/ui-react/src/lib/Components/Select/Select.tsx +++ b/libs/ui-react/src/lib/Components/Select/Select.tsx @@ -376,7 +376,8 @@ const SelectItemDescription = ({ const SelectSearch = ({ className, placeholder = 'Search', - errorMessage, + helperText, + status, 'aria-invalid': ariaInvalid, suffix, onClear, @@ -395,7 +396,8 @@ const SelectSearch = ({ ), }, diff --git a/libs/ui-react/src/lib/Components/TextInput/TextInput.mdx b/libs/ui-react/src/lib/Components/TextInput/TextInput.mdx index ddc23f415..3885c2b79 100644 --- a/libs/ui-react/src/lib/Components/TextInput/TextInput.mdx +++ b/libs/ui-react/src/lib/Components/TextInput/TextInput.mdx @@ -55,18 +55,11 @@ Use `onClear` to extend the default clear behavior with custom logic: The input supports error handling through: -- `aria-invalid`: Controls the error state styling -- `errorMessage`: Displays an error message below the input -- Error styling includes a red ring and text color +- `status`: Set to `'error'` or `'success'` to style the input state; `'error'` updates the border, label, and helper text styling, while `'success'` updates the border and helper/icon styling. Omit for a neutral hint. Setting `status='error'` automatically sets `aria-invalid={true}` on the input for screen readers. +- `helperText`: Optional copy shown below the input (pair with `status` for error or success messaging and state styling) -The error message will be automatically: - -- Connected to the input using aria-describedby -- Displayed with a warning icon -- Styled in the error color -- Announced by screen readers ### Custom Right Element @@ -78,31 +71,13 @@ You can add: Tooltips, Action buttons, Custom icons, Any interactive element > **Important note:** The clear button automatically appears when input has content and will take priority over your `suffix`. Use `hideClearButton` to prevent this if you need persistent right-side content. -### Interactive in a Form - - - -## Input Types - -The component supports various HTML input types: - -- text (default) -- email -- password -- number -- tel -- url +### Label and placeholder -## Accessibility + -The Input component follows accessibility best practices: +### Interactive in a Form -- Uses semantic HTML with proper ARIA attributes -- Supports keyboard navigation -- Labels are properly associated with inputs using htmlFor -- Error messages are linked to inputs using aria-describedby -- Clear button has an accessible label -- Error states are communicated through aria-invalid + ## Controlled vs Uncontrolled @@ -116,12 +91,6 @@ The Input component supports both controlled and uncontrolled usage: -## Label vs Placeholder - -> ⚠️ **Important:** Do not combine `label` and `placeholder` props together. They will visually overlap and create a poor user experience. - -The Input component uses **floating labels** that animate from inside the input field. Using both label and placeholder simultaneously will cause them to overlap and interfere with each other. - ## Best Practices **Labels**: Always provide clear, descriptive labels @@ -134,7 +103,27 @@ The Input component uses **floating labels** that animate from inside the input **Types**: Use the appropriate input type for the data being collected -**Label vs Placeholder**: Choose one approach - don't mix floating labels with placeholders +## Input Types + +The component supports various HTML input types: + +- text (default) +- email +- password +- number +- tel +- url + +## Accessibility + +The Input component follows accessibility best practices: + +- Uses semantic HTML with proper ARIA attributes +- Supports keyboard navigation +- Labels are properly associated with inputs using htmlFor +- Error messages are linked to inputs using aria-describedby +- Clear button has an accessible label +- Error states are driven by `status='error'`, which automatically sets `aria-invalid={true}` on the input for screen readers @@ -273,20 +262,14 @@ function MyComponent() { label='Username' value={value} onChange={handleChange} - aria-invalid={error} - errorMessage={error ? 'Must be at least 3 characters' : undefined} + helperText={error ? 'Must be at least 3 characters' : undefined} + status={error ? 'error' : undefined} /> ); } ``` -> **Note:** Error message are optional. Use `errorMessage` to display an error message below the input. - -## ⚠️ Label vs Placeholder - -**Important:** Do not combine `label` and `placeholder` props together. They will visually overlap and create a poor user experience. - -The Input component uses floating labels that animate from inside the input field. Using both simultaneously causes visual conflicts. +> **Note:** Helper text is optional. Use `helperText` with `status="error"` to show validation feedback below the input. ## Do's and Don'ts @@ -362,8 +345,8 @@ The Input component uses floating labels that animate from inside the input fiel {/* prettier-ignore */} @@ -371,15 +354,15 @@ The Input component uses floating labels that animate from inside the input fiel ``` {/* prettier-ignore */} @@ -387,7 +370,7 @@ The Input component uses floating labels that animate from inside the input fiel ``` @@ -397,41 +380,31 @@ The Input component uses floating labels that animate from inside the input fiel {/* prettier-ignore */} ```tsx -// Use floating label (recommended) - setValue(e.target.value)} -/> - -// OR use placeholder (alternative) - setValue(e.target.value)} + onChange={(e) => setValue(e.target.value)} /> ``` {/* prettier-ignore */} ```tsx -// DON'T DO THIS - causes visual overlap - setValue(e.target.value)} + onChange={(e) => setValue(e.target.value)} /> ``` diff --git a/libs/ui-react/src/lib/Components/TextInput/TextInput.stories.tsx b/libs/ui-react/src/lib/Components/TextInput/TextInput.stories.tsx index 447ccf3cf..3b582963d 100644 --- a/libs/ui-react/src/lib/Components/TextInput/TextInput.stories.tsx +++ b/libs/ui-react/src/lib/Components/TextInput/TextInput.stories.tsx @@ -62,7 +62,6 @@ export const Default: Story = { label: 'Label', type: 'text', disabled: false, - 'aria-invalid': false, value: '', onClear: undefined, suffix: undefined, @@ -108,6 +107,36 @@ export const WithContent: Story = { }, }; +/** + * `label` and `placeholder` together (empty field shows floated label and hint text). + */ +export const WithLabelAndPlaceholder: Story = { + render: () => { + const [value, setValue] = useState(''); + return ( + setValue(e.target.value)} + className='max-w-md' + /> + ); + }, + parameters: { + docs: { + source: { + code: ` setValue(e.target.value)} +/>`, + }, + }, + }, +}; + export const ExtendedClearBehavior: Story = { render: () => { return ( @@ -211,10 +240,10 @@ export const WithError: Story = { type='email' value={email} onChange={(e) => setEmail(e.target.value)} - aria-invalid={!isValidEmail} - errorMessage={ + helperText={ !isValidEmail ? 'Please enter a valid email address' : undefined } + status={!isValidEmail ? 'error' : undefined} />
Try typing a valid email address or clicking the clear button to @@ -225,6 +254,47 @@ export const WithError: Story = { }, }; +/** + * Success feedback below the input. + */ +export const WithSuccess: Story = { + render: () => ( +
+ { + console.log('onChange'); + }} + helperText='Address verified' + status='success' + /> +
+ ), +}; + +/** + * Neutral hint (no status): muted helper text without an icon. + */ +export const WithNeutralHint: Story = { + render: () => { + const [value, setValue] = useState(''); + return ( +
+ ) => + setValue(e.target.value) + } + helperText='Enter your ETH address' + /> +
+ ); + }, +}; + /** * Disabled input state. */ @@ -429,8 +499,8 @@ export const Interactive: Story = { value={formData.username} onChange={handleChange('username')} onClear={handleClear('username')} - aria-invalid={!!errors.username} - errorMessage={errors.username} + helperText={errors.username} + status={errors.username ? 'error' : undefined} suffix={} /> @@ -440,8 +510,8 @@ export const Interactive: Story = { value={formData.email} onChange={handleChange('email')} onClear={handleClear('email')} - aria-invalid={!!errors.email} - errorMessage={errors.email} + helperText={errors.email} + status={errors.email ? 'error' : undefined} />
diff --git a/libs/ui-react/src/lib/Components/TextInput/TextInput.test.tsx b/libs/ui-react/src/lib/Components/TextInput/TextInput.test.tsx index 26f93d893..db2bb296b 100644 --- a/libs/ui-react/src/lib/Components/TextInput/TextInput.test.tsx +++ b/libs/ui-react/src/lib/Components/TextInput/TextInput.test.tsx @@ -23,7 +23,7 @@ describe('Input Component', () => { expect(inputElement).toHaveAttribute('placeholder', ' '); }); - it('should render with error state when aria-invalid is true', () => { + it('should forward aria-invalid to the input when passed explicitly', () => { render( { expect(ref.current).toBeInstanceOf(HTMLInputElement); }); - it('should set aria-describedby and show error message', () => { - const errorMessage = 'This field is required'; + it('should set aria-describedby and show helper text for error status', () => { + const helperText = 'This field is required'; render( , ); @@ -91,36 +92,69 @@ describe('Input Component', () => { const inputElement = screen.getByRole('textbox'); expect(inputElement).toHaveAttribute( 'aria-describedby', - 'test-input-error', + 'test-input-helper', ); expect(inputElement).toHaveAttribute('aria-invalid', 'true'); - const errorElement = screen.getByText(errorMessage); + const errorElement = screen.getByText(helperText); expect(errorElement).toBeInTheDocument(); expect(errorElement.parentElement).toHaveAttribute( 'id', - 'test-input-error', + 'test-input-helper', ); expect(errorElement.parentElement).toHaveAttribute('role', 'alert'); }); - it('should show error message with icon', () => { - const errorMessage = 'This field is required'; + it('should show error helper with icon', () => { + const helperText = 'This field is required'; render( , ); - const messageElement = screen.getByText(errorMessage); + const messageElement = screen.getByText(helperText); expect(messageElement).toBeInTheDocument(); const errorIcon = document.querySelector('svg.text-error'); expect(errorIcon).toBeInTheDocument(); }); + it('should show neutral helper without alert role', () => { + const helperText = 'Enter your ETH address'; + render( + , + ); + + const inputElement = screen.getByRole('textbox'); + expect(inputElement).toHaveAttribute('aria-describedby', 'addr-helper'); + expect(screen.getByText(helperText)).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('should show success helper with icon', () => { + const helperText = 'Address verified'; + render( + , + ); + + expect(screen.getByText(helperText)).toBeInTheDocument(); + expect(document.querySelector('svg.text-success')).toBeInTheDocument(); + }); + it('should accept all standard HTML input props', () => { render( setTitle(e.target.value)} /> * - * // Input with error state + * // Input with error helper (aria-invalid is auto-set when status="error") * setEmail(e.target.value)} - * aria-invalid={!isValid} - * errorMessage="Please enter a valid email address" + * helperText="Please enter a valid email address" + * status="error" * /> * * // Input with suffix element diff --git a/libs/ui-rnative/src/lib/Components/AddressInput/AddressInput.mdx b/libs/ui-rnative/src/lib/Components/AddressInput/AddressInput.mdx index 344d6faf2..56d86e23d 100644 --- a/libs/ui-rnative/src/lib/Components/AddressInput/AddressInput.mdx +++ b/libs/ui-rnative/src/lib/Components/AddressInput/AddressInput.mdx @@ -55,16 +55,10 @@ Use `hideClearButton` to prevent the clear button from appearing. ### Error State -The input supports error handling through `errorMessage` which displays an error message below the input with error styling including a red border and text color. +The input supports error handling through `helperText` and `status` (`'error'` \| `'success'`), which show copy below the input with matching border and text styling. -The error message will be automatically: - -- Connected to the input -- Displayed with a warning icon -- Styled in the error color -- Announced by screen readers ### Disabled State @@ -244,7 +238,8 @@ function MyComponent() { placeholder='Enter address or ENS' value={address} onChangeText={setAddress} - errorMessage={error} + helperText={error || undefined} + status={error ? 'error' : undefined} onClear={() => { // Default clearing happens automatically // Add your custom logic here @@ -289,14 +284,15 @@ function MyComponent() { placeholder='Enter address or ENS' value={address} onChangeText={handleChange} - errorMessage={error} + helperText={error || undefined} + status={error ? 'error' : undefined} onQrCodeClick={() => openQrScanner()} /> ); } ``` -> **Note:** Error messages are optional. Use `errorMessage` to display an error message below the input. +> **Note:** Helper text is optional. Use `helperText` with `status="error"` to show validation feedback below the input. ### With Custom Styling @@ -387,7 +383,8 @@ function MyComponent() { value={address} onChangeText={setAddress} onQrCodeClick={handleQrScan} - errorMessage={error} + helperText={error || undefined} + status={error ? 'error' : undefined} /> ); } @@ -412,7 +409,8 @@ function TransactionForm() { placeholder='Enter address or ENS' value={recipientAddress} onChangeText={setRecipientAddress} - errorMessage={addressError} + helperText={addressError || undefined} + status={addressError ? 'error' : undefined} onQrCodeClick={() => openQrScanner()} style={{ maxWidth: 320 }} /> diff --git a/libs/ui-rnative/src/lib/Components/AddressInput/AddressInput.stories.tsx b/libs/ui-rnative/src/lib/Components/AddressInput/AddressInput.stories.tsx index bcf6b4e85..6ce1ca065 100644 --- a/libs/ui-rnative/src/lib/Components/AddressInput/AddressInput.stories.tsx +++ b/libs/ui-rnative/src/lib/Components/AddressInput/AddressInput.stories.tsx @@ -119,7 +119,8 @@ export const WithError: Story = { args: { placeholder: 'Enter address or ENS', value: 'invalid-address', - errorMessage: 'Invalid address format', + helperText: 'Invalid address format', + status: 'error', prefix: 'To:', editable: true, hideClearButton: false, diff --git a/libs/ui-rnative/src/lib/Components/AmountInput/AmountInput.mdx b/libs/ui-rnative/src/lib/Components/AmountInput/AmountInput.mdx index 17d590e7f..993d52f36 100644 --- a/libs/ui-rnative/src/lib/Components/AmountInput/AmountInput.mdx +++ b/libs/ui-rnative/src/lib/Components/AmountInput/AmountInput.mdx @@ -385,7 +385,7 @@ const [hasError, setHasError] = useState(false); {/* prettier-ignore */} @@ -400,8 +400,8 @@ const [hasError, setHasError] = useState(false); value={value} onChangeText={setValue} currencyText='USD' - error={true} - errorMessage='Invalid amount' + status={hasError ? 'error' : undefined} + helperText={hasError ? 'Invalid amount' : undefined} /> ``` diff --git a/libs/ui-rnative/src/lib/Components/BaseInput/BaseInput.tsx b/libs/ui-rnative/src/lib/Components/BaseInput/BaseInput.tsx index 273c349e4..261d693ad 100644 --- a/libs/ui-rnative/src/lib/Components/BaseInput/BaseInput.tsx +++ b/libs/ui-rnative/src/lib/Components/BaseInput/BaseInput.tsx @@ -1,5 +1,6 @@ import { DisabledProvider, + resolveBaseInputPlaceholder, useDisabledContext, } from '@ledgerhq/lumen-utils-shared'; import { @@ -21,6 +22,7 @@ import { useCommonTranslation } from '../../../i18n'; import type { LumenStyleSheetTheme } from '../../../styles'; import { useStyleSheet, useTheme } from '../../../styles'; import { useTimingConfig } from '../../Animations/useTimingConfig'; +import { CheckmarkCircleFill } from '../../Symbols/Icons/CheckmarkCircleFill'; import { DeleteCircleFill } from '../../Symbols/Icons/DeleteCircleFill'; import { RuntimeConstants } from '../../utils'; import { InteractiveIcon } from '../InteractiveIcon'; @@ -34,7 +36,8 @@ export const BaseInput = ({ inputStyle, labelStyle, label, - errorMessage, + helperText, + status, hideClearButton, onChangeText: onChangeTextProp, editable, @@ -42,6 +45,7 @@ export const BaseInput = ({ prefix, suffix, ref, + placeholder: placeholderProp, ...props }: BaseInputProps) => { const disabled = useDisabledContext({ @@ -65,6 +69,12 @@ export const BaseInput = ({ ? !!props.value && props.value.length > 0 : uncontrolledValue.length > 0; + const { inputPlaceholder, labelStaysFloatedWithPlaceholder } = + resolveBaseInputPlaceholder({ + label, + placeholder: placeholderProp, + }); + const showClearButton = hasContent && !disabled && !hideClearButton; const handleChangeText = useCallback( @@ -87,7 +97,7 @@ export const BaseInput = ({ }; const styles = useStyles({ - hasError: !!errorMessage, + status, isFocused, isEditable: !disabled, hasLabel: !!label, @@ -97,8 +107,9 @@ export const BaseInput = ({ hasContent, isFocused, showClearButton, - hasError: !!errorMessage, + status, isEditable: !disabled, + labelStaysFloatedWithPlaceholder, }); return ( @@ -114,6 +125,7 @@ export const BaseInput = ({ setIsFocused(true)} onBlur={() => setIsFocused(false)} @@ -158,10 +170,13 @@ export const BaseInput = ({ )} - {errorMessage && ( - - - {errorMessage} + {!!helperText && ( + + {status === 'error' && } + {status === 'success' && ( + + )} + {helperText} )} @@ -170,18 +185,25 @@ export const BaseInput = ({ }; const useStyles = ({ - hasError, + status, isFocused, isEditable, hasLabel, }: { - hasError: boolean; + status: 'error' | 'success' | undefined; isFocused: boolean; isEditable: boolean; hasLabel: boolean; }) => { return useStyleSheet( (t) => { + const hasStatusBorder = status === 'error' || status === 'success'; + const statusBorderColors = { + error: t.colors.border.error, + success: t.colors.border.success, + } as const; + const statusBorderColor = status ? statusBorderColors[status] : undefined; + return { container: StyleSheet.flatten([ { @@ -198,15 +220,16 @@ const useStyles = ({ borderColor: 'transparent', overflow: 'hidden', }, - hasError && { - borderWidth: 1, - borderColor: t.colors.border.error, - }, + hasStatusBorder && + statusBorderColor && { + borderWidth: isFocused ? t.borderWidth.s2 : t.borderWidth.s1, + borderColor: statusBorderColor, + }, !isEditable && { backgroundColor: t.colors.bg.disabled, }, isFocused && - !hasError && + !hasStatusBorder && isEditable && { borderColor: t.colors.border.active }, ]), input: StyleSheet.flatten([ @@ -217,31 +240,37 @@ const useStyles = ({ color: t.colors.text.base, backgroundColor: t.colors.bg.muted, outline: 'none', - ...t.typographies.body2, - paddingTop: t.spacings.s12, - paddingBottom: t.spacings.s12, + ...t.typographies.body1, + paddingTop: t.spacings.s4, + paddingBottom: t.spacings.s2, }, hasLabel && { paddingTop: t.spacings.s20, paddingBottom: t.spacings.s4, paddingHorizontal: 0, + ...t.typographies.body2, }, - RuntimeConstants.isIOS && hasLabel && { lineHeight: 0 }, + RuntimeConstants.isIOS && { lineHeight: 0 }, RuntimeConstants.isAndroid && { includeFontPadding: false }, !isEditable && { backgroundColor: t.colors.bg.disabled, color: t.colors.text.disabled, }, ]), - errorContainer: { + helperContainer: { marginTop: t.spacings.s8, flexDirection: 'row', alignItems: 'center', gap: t.spacings.s2, }, - errorText: { - color: t.colors.text.error, + helperText: { ...t.typographies.body3, + flex: 1, + color: { + error: t.colors.text.error, + success: t.colors.text.success, + default: t.colors.text.muted, + }[status ?? 'default'], }, suffixContainer: { minWidth: t.sizes.s20, @@ -250,7 +279,7 @@ const useStyles = ({ }, }; }, - [hasError, isFocused, isEditable, hasLabel], + [status, isFocused, isEditable, hasLabel], ); }; @@ -296,14 +325,16 @@ const useFloatingLabelStyles = ({ isFocused, hasContent, showClearButton, - hasError, + status, isEditable, + labelStaysFloatedWithPlaceholder, }: { isFocused: boolean; hasContent: boolean; showClearButton: boolean; - hasError: boolean; + status: 'error' | 'success' | undefined; isEditable: boolean; + labelStaysFloatedWithPlaceholder: boolean; }) => { const { theme } = useTheme(); @@ -320,20 +351,21 @@ const useFloatingLabelStyles = ({ showClearButton && { width: '92%', }, + status === 'error' && { + color: t.colors.text.error, + }, !isEditable && { color: t.colors.text.disabled, }, - hasError && { - color: t.colors.text.error, - }, ]), }), - [hasContent, showClearButton, hasError, isEditable], + [hasContent, showClearButton, status, isEditable], ); const { animatedStyle } = useAnimatedFloatingLabel({ theme, - isFloatingLabel: isFocused || hasContent, + isFloatingLabel: + isFocused || hasContent || labelStaysFloatedWithPlaceholder, }); return { label, animatedStyle }; diff --git a/libs/ui-rnative/src/lib/Components/BaseInput/types.ts b/libs/ui-rnative/src/lib/Components/BaseInput/types.ts index d439096cf..b9fbb6f0b 100644 --- a/libs/ui-rnative/src/lib/Components/BaseInput/types.ts +++ b/libs/ui-rnative/src/lib/Components/BaseInput/types.ts @@ -6,6 +6,8 @@ import type { } from 'react-native'; import type { BoxProps } from '../Utility'; +export type BaseInputStatus = 'error' | 'success'; + export type BaseInputProps = { /** * The label text that floats above the input when focused or filled. @@ -35,9 +37,15 @@ export type BaseInputProps = { */ labelStyle?: StyleProp; /** - * An optional error message displayed below the input. + * Optional text shown below the input (hint, error, or success copy). + * Pair with `status` for error/success styling and icons; omit `status` for a neutral hint. + */ + helperText?: string; + /** + * Visual state for border, helper text, helper icon, and the label in error state. + * Omit when `helperText` is a neutral hint. */ - errorMessage?: string; + status?: BaseInputStatus; /** * Custom content to render after the input (right side in LTR). * @example suffix={} diff --git a/libs/ui-rnative/src/lib/Components/MediaImage/MediaImage.test.tsx b/libs/ui-rnative/src/lib/Components/MediaImage/MediaImage.test.tsx index c63ae73e2..f65caf9bd 100644 --- a/libs/ui-rnative/src/lib/Components/MediaImage/MediaImage.test.tsx +++ b/libs/ui-rnative/src/lib/Components/MediaImage/MediaImage.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect } from '@jest/globals'; import { ledgerLiveThemes } from '@ledgerhq/lumen-design-core'; -import { render, waitFor } from '@testing-library/react-native'; +import { act, render, waitFor } from '@testing-library/react-native'; import { ThemeProvider } from '../ThemeProvider/ThemeProvider'; import { MediaImage } from './MediaImage'; @@ -89,7 +89,9 @@ describe('MediaImage Component', () => { ); const img = getByTestId('media-image-img'); - img.props.onError(); + act(() => { + img.props.onError(); + }); rerender( @@ -140,7 +142,9 @@ describe('MediaImage Component', () => { ); const img = getByTestId('media-image-img'); - img.props.onError(); + act(() => { + img.props.onError(); + }); rerender( diff --git a/libs/ui-rnative/src/lib/Components/SearchInput/SearchInput.mdx b/libs/ui-rnative/src/lib/Components/SearchInput/SearchInput.mdx index 1c9b9fc4c..4568da092 100644 --- a/libs/ui-rnative/src/lib/Components/SearchInput/SearchInput.mdx +++ b/libs/ui-rnative/src/lib/Components/SearchInput/SearchInput.mdx @@ -67,17 +67,10 @@ Alternatively, use `editable={false}` to prevent editing without applying the mu ### Error State -The search component supports error handling through `errorMessage` which displays an error message below the input with error styling including a red border and text color. +The search component supports error handling through `helperText` and `status` (`'error'` \| `'success'`), which show copy below the input with matching border and text styling. -The error message will be automatically: - -- Connected to the input -- Displayed with a warning icon -- Styled in the error color -- Announced by screen readers - ## Controlled vs Uncontrolled The SearchInput component supports both controlled and uncontrolled usage. @@ -232,13 +225,14 @@ function MyComponent() { placeholder='Search products' value={query} onChangeText={handleSearch} - errorMessage={error} + helperText={error || undefined} + status={error ? 'error' : undefined} /> ); } ``` -> **Note:** Error messages are optional. Use `errorMessage` to display an error message below the search input. +> **Note:** Helper text is optional. Use `helperText` with `status="error"` to show validation feedback below the search input. ### With Custom Styling diff --git a/libs/ui-rnative/src/lib/Components/SearchInput/SearchInput.stories.tsx b/libs/ui-rnative/src/lib/Components/SearchInput/SearchInput.stories.tsx index 7d86db580..d13feb3cd 100644 --- a/libs/ui-rnative/src/lib/Components/SearchInput/SearchInput.stories.tsx +++ b/libs/ui-rnative/src/lib/Components/SearchInput/SearchInput.stories.tsx @@ -75,7 +75,8 @@ export const WithError: Story = { ), args: { placeholder: 'Search products', - errorMessage: 'Search term is invalid', + helperText: 'Search term is invalid', + status: 'error', editable: true, hideClearButton: false, }, diff --git a/libs/ui-rnative/src/lib/Components/TextInput/TextInput.mdx b/libs/ui-rnative/src/lib/Components/TextInput/TextInput.mdx index 003038e14..b08c8e6a6 100644 --- a/libs/ui-rnative/src/lib/Components/TextInput/TextInput.mdx +++ b/libs/ui-rnative/src/lib/Components/TextInput/TextInput.mdx @@ -34,6 +34,10 @@ The label text automatically floats above the input when content is entered, pro +### Label and placeholder + + + ### Clear Button A clear button (×) appears **automatically** when input has content. @@ -54,17 +58,10 @@ Use `onClear` to extend the default clear behavior with custom logic. ### Error State -The input supports error handling through `errorMessage` which displays an error message below the input with error styling including a red border and text color. +The input supports error handling through `helperText` and `status` (`'error'` \| `'success'`), which show copy below the input with matching border and text styling. -The error message will be automatically: - -- Connected to the input -- Displayed with a warning icon -- Styled in the error color -- Announced by screen readers - ### Disabled State The input can be fully disabled using the `disabled` prop, which prevents interaction and applies a muted visual style. @@ -221,13 +218,14 @@ function MyComponent() { label='Username' value={value} onChangeText={handleChange} - errorMessage={error} + helperText={error || undefined} + status={error ? 'error' : undefined} /> ); } ``` -> **Note:** Error messages are optional. Use `errorMessage` to display an error message below the input. +> **Note:** Helper text is optional. Use `helperText` with `status="error"` to show validation feedback below the input. ### With Custom Styling diff --git a/libs/ui-rnative/src/lib/Components/TextInput/TextInput.stories.tsx b/libs/ui-rnative/src/lib/Components/TextInput/TextInput.stories.tsx index 702af244d..360e5818a 100644 --- a/libs/ui-rnative/src/lib/Components/TextInput/TextInput.stories.tsx +++ b/libs/ui-rnative/src/lib/Components/TextInput/TextInput.stories.tsx @@ -66,11 +66,51 @@ export const WithContent: Story = { }, }; +export const WithLabelAndPlaceholder: Story = { + render: (args) => , + args: { + label: 'Phone', + placeholder: '+1 (555) 000-0000', + editable: true, + hideClearButton: false, + keyboardType: 'phone-pad', + }, +}; + export const WithError: Story = { render: (args) => , args: { label: 'Username', - errorMessage: 'Username must be at least 3 characters', + helperText: 'Username must be at least 3 characters', + status: 'error', + editable: true, + hideClearButton: false, + keyboardType: 'default', + }, +}; + +export const WithSuccess: Story = { + render: (args) => ( + + ), + args: { + label: 'Address', + helperText: 'Address verified', + status: 'success', + editable: true, + hideClearButton: false, + keyboardType: 'default', + }, +}; + +export const WithNeutralHint: Story = { + render: (args) => , + args: { + label: 'Address', + helperText: 'Enter your ETH address', editable: true, hideClearButton: false, keyboardType: 'default', diff --git a/libs/ui-rnative/src/lib/Components/TextInput/TextInput.test.tsx b/libs/ui-rnative/src/lib/Components/TextInput/TextInput.test.tsx new file mode 100644 index 000000000..7e77ebd1f --- /dev/null +++ b/libs/ui-rnative/src/lib/Components/TextInput/TextInput.test.tsx @@ -0,0 +1,96 @@ +import { describe, expect, it } from '@jest/globals'; +import { ledgerLiveThemes } from '@ledgerhq/lumen-design-core'; +import { render, screen } from '@testing-library/react-native'; +import type { ReactElement } from 'react'; +import { CheckmarkCircleFill } from '../../Symbols/Icons/CheckmarkCircleFill'; +import { DeleteCircleFill } from '../../Symbols/Icons/DeleteCircleFill'; +import { ThemeProvider } from '../ThemeProvider/ThemeProvider'; +import { TextInput } from './TextInput'; + +const { colors } = ledgerLiveThemes.dark; + +const renderWithProvider = ( + component: ReactElement, +): ReturnType => { + return render( + + {component} + , + ); +}; + +describe('TextInput', () => { + describe('Placeholder and label (BaseInput parity with web)', () => { + it('uses the provided placeholder when label and placeholder are both set', async () => { + await renderWithProvider( + {}} + />, + ); + + expect(screen.getByPlaceholderText('jane.doe')).toBeTruthy(); + }); + }); + + describe('Helper text', () => { + it('renders neutral helper text without feedback icons', () => { + renderWithProvider( + , + ); + + const helperText = screen.getByText('Enter your ETH address'); + + expect(helperText).toBeTruthy(); + expect(helperText.props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ color: colors.text.muted }), + ]), + ); + expect(screen.UNSAFE_queryByType(DeleteCircleFill)).toBeNull(); + expect(screen.UNSAFE_queryByType(CheckmarkCircleFill)).toBeNull(); + }); + + it('renders error helper text with an error icon', () => { + renderWithProvider( + , + ); + + const helperText = screen.getByText('Invalid address format'); + + expect(helperText.props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ color: colors.text.error }), + ]), + ); + expect(screen.UNSAFE_getByType(DeleteCircleFill)).toBeTruthy(); + expect(screen.UNSAFE_queryByType(CheckmarkCircleFill)).toBeNull(); + }); + + it('renders success helper text with a success icon', () => { + renderWithProvider( + , + ); + + const helperText = screen.getByText('Address verified'); + + expect(helperText.props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ color: colors.text.success }), + ]), + ); + expect(screen.UNSAFE_getByType(CheckmarkCircleFill)).toBeTruthy(); + expect(screen.UNSAFE_queryByType(DeleteCircleFill)).toBeNull(); + }); + }); +}); diff --git a/libs/ui-rnative/src/lib/Components/TextInput/TextInput.tsx b/libs/ui-rnative/src/lib/Components/TextInput/TextInput.tsx index 25a0e0623..0e6107150 100644 --- a/libs/ui-rnative/src/lib/Components/TextInput/TextInput.tsx +++ b/libs/ui-rnative/src/lib/Components/TextInput/TextInput.tsx @@ -8,7 +8,7 @@ import { type TextInputProps } from './types'; * - **Automatic clear button** appears when input has content * - **Floating label** with smooth animations * - **Suffix elements** for icons, buttons, or custom content - * - **Error state styling** with errorMessage support + * - **Helper text** with optional `status` (`error` | `success`) for border and helper feedback styling * - **Container-based spacing** with padding and gap for clean layout * - **Flexible styling** via style props * - **React Native TextInput** with proper mobile behavior @@ -28,12 +28,13 @@ import { type TextInputProps } from './types'; * // Basic input with automatic clear button * * - * // Input with error state + * // Input with error helper * * * // Input with suffix element diff --git a/libs/utils-shared/src/index.ts b/libs/utils-shared/src/index.ts index facc9277b..09808dee2 100644 --- a/libs/utils-shared/src/index.ts +++ b/libs/utils-shared/src/index.ts @@ -11,3 +11,4 @@ export * from './lib/throttle'; export * from './lib/debounce'; export * from './lib/a11y'; export * from './lib/shallowEqual'; +export * from './lib/resolveBaseInputPlaceholder'; diff --git a/libs/utils-shared/src/lib/resolveBaseInputPlaceholder/index.ts b/libs/utils-shared/src/lib/resolveBaseInputPlaceholder/index.ts new file mode 100644 index 000000000..dfc464ac6 --- /dev/null +++ b/libs/utils-shared/src/lib/resolveBaseInputPlaceholder/index.ts @@ -0,0 +1 @@ +export * from './resolveBaseInputPlaceholder'; diff --git a/libs/utils-shared/src/lib/resolveBaseInputPlaceholder/resolveBaseInputPlaceholder.test.ts b/libs/utils-shared/src/lib/resolveBaseInputPlaceholder/resolveBaseInputPlaceholder.test.ts new file mode 100644 index 000000000..0ba21160b --- /dev/null +++ b/libs/utils-shared/src/lib/resolveBaseInputPlaceholder/resolveBaseInputPlaceholder.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { resolveBaseInputPlaceholder } from './resolveBaseInputPlaceholder'; + +describe('resolveBaseInputPlaceholder', () => { + it('with label: uses a single space when no user placeholder (float hack)', () => { + expect( + resolveBaseInputPlaceholder({ label: 'Email', placeholder: undefined }), + ).toEqual({ + inputPlaceholder: ' ', + labelStaysFloatedWithPlaceholder: false, + }); + }); + + it('with label: uses user placeholder and keeps label floated alongside it', () => { + expect( + resolveBaseInputPlaceholder({ + label: 'Username', + placeholder: 'jane.doe', + }), + ).toEqual({ + inputPlaceholder: 'jane.doe', + labelStaysFloatedWithPlaceholder: true, + }); + }); + + it('with label: treats whitespace-only placeholder as absent', () => { + expect( + resolveBaseInputPlaceholder({ label: 'x', placeholder: ' ' }), + ).toEqual({ + inputPlaceholder: ' ', + labelStaysFloatedWithPlaceholder: false, + }); + }); + + it('without label: defaults omitted placeholder to a single space', () => { + expect( + resolveBaseInputPlaceholder({ label: undefined, placeholder: undefined }), + ).toEqual({ + inputPlaceholder: ' ', + labelStaysFloatedWithPlaceholder: false, + }); + }); + + it('without label: passes through user placeholder', () => { + expect( + resolveBaseInputPlaceholder({ label: undefined, placeholder: 'Search' }), + ).toEqual({ + inputPlaceholder: 'Search', + labelStaysFloatedWithPlaceholder: false, + }); + }); + + it('without label: keeps empty string (not coerced to space)', () => { + expect( + resolveBaseInputPlaceholder({ label: undefined, placeholder: '' }), + ).toEqual({ + inputPlaceholder: '', + labelStaysFloatedWithPlaceholder: false, + }); + }); +}); diff --git a/libs/utils-shared/src/lib/resolveBaseInputPlaceholder/resolveBaseInputPlaceholder.ts b/libs/utils-shared/src/lib/resolveBaseInputPlaceholder/resolveBaseInputPlaceholder.ts new file mode 100644 index 000000000..3f2db17c6 --- /dev/null +++ b/libs/utils-shared/src/lib/resolveBaseInputPlaceholder/resolveBaseInputPlaceholder.ts @@ -0,0 +1,44 @@ +/** Single space: drives floating-label / placeholder-shown behavior when no user-facing placeholder is set. */ +const PLACEHOLDER_FOR_FLOAT = ' '; + +export type ResolveBaseInputPlaceholderArgs = { + label: string | undefined; + /** Raw `placeholder` from the input props (before spreading onto the field). */ + placeholder: string | undefined; +}; + +export type ResolveBaseInputPlaceholderResult = { + /** Value to set on the native input `placeholder`. */ + inputPlaceholder: string; + /** + * When a floating label and a non-empty user placeholder are both in use, the label must + * stay in the “floated” slot (not the empty-field centered position). + */ + labelStaysFloatedWithPlaceholder: boolean; +}; + +/** + * Derives the native `placeholder` and floating-label mode for Lumen `BaseInput`. + * Web and React Native share this so label + example copy, and prefix-only fields, stay consistent. + */ +export const resolveBaseInputPlaceholder = ({ + label, + placeholder, +}: ResolveBaseInputPlaceholderArgs): ResolveBaseInputPlaceholderResult => { + const userText = + typeof placeholder === 'string' && placeholder.trim().length > 0 + ? placeholder + : undefined; + + if (label) { + return { + inputPlaceholder: userText ?? PLACEHOLDER_FOR_FLOAT, + labelStaysFloatedWithPlaceholder: userText !== undefined, + }; + } + + return { + inputPlaceholder: placeholder ?? PLACEHOLDER_FOR_FLOAT, + labelStaysFloatedWithPlaceholder: false, + }; +};